Expose /cfg to read and write an on-disk config file
Add a root /cfg file to the 9P namespace backed by a TOML config file on physical storage, so clients can read and change server settings without restarting. The first setting is org_path: the directory scanned for org files, with a leading ~ expanded to $HOME. New config module (config.rs): - Resolve the config file at $XDG_CONFIG_HOME/orkmode/config.toml, else ~/.config/orkmode/config.toml. - expand_tilde(), read_raw(), write_raw(), parse(), and load(). - Config holds an optional org_path. Unit tests cover tilde expansion (bare ~, ~/sub, non-tilde, ~user left intact) and TOML parsing. Mutable org directory (state.rs): - org_dir becomes an RwLock<PathBuf> with an org_dir() getter and a set_org_dir() that validates the directory and rescans. - config_raw() backs the /cfg read; apply_config() validates the TOML and the target directory, applies org_path, then persists. A rejected write (bad TOML or missing directory) never reaches disk and never mutates state. Namespace (namespace.rs): - /cfg is a read+write file: read returns the raw config, write calls apply_config. Startup (ork-server.rs): - With no -d/positional directory, fall back to the config file's org_path (tilde-expanded), else the current directory. An explicit CLI directory still takes precedence. Help text documents the config file. Verified over 9P with an isolated HOME: /cfg reads empty on a fresh install; writing org_path="~/org" expands the tilde and live-switches the served documents; absolute paths work; nonexistent directories and invalid TOML are rejected without changing state or disk; the setting persists; and a no-flag restart loads org_path from the config file. Server tests: 61 passed.
This commit is contained in:
parent
b385429d1b
commit
7c9779b2fc
|
|
@ -13,7 +13,7 @@ fn main() -> io::Result<()> {
|
|||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
// Parse arguments
|
||||
let mut org_dir = env::current_dir()?;
|
||||
let mut org_dir: Option<PathBuf> = None;
|
||||
let mut addr = "tcp://localhost:5640".to_string();
|
||||
|
||||
let mut i = 1;
|
||||
|
|
@ -22,7 +22,7 @@ fn main() -> io::Result<()> {
|
|||
"-d" | "--dir" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
org_dir = PathBuf::from(&args[i]);
|
||||
org_dir = Some(PathBuf::from(&args[i]));
|
||||
}
|
||||
}
|
||||
"-a" | "--addr" => {
|
||||
|
|
@ -37,11 +37,28 @@ fn main() -> io::Result<()> {
|
|||
}
|
||||
_ => {
|
||||
// Assume it's the org directory
|
||||
org_dir = PathBuf::from(&args[i]);
|
||||
org_dir = Some(PathBuf::from(&args[i]));
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Resolve the org directory. A CLI directory takes precedence; otherwise
|
||||
// fall back to the config file's org_path (with leading `~` expanded), and
|
||||
// finally to the current working directory.
|
||||
let org_dir = match org_dir {
|
||||
Some(dir) => dir,
|
||||
None => match ork_server::config::load() {
|
||||
Ok(cfg) => match cfg.org_path.as_deref() {
|
||||
Some(p) => ork_server::config::expand_tilde(p),
|
||||
None => env::current_dir()?,
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to read config: {}", e);
|
||||
env::current_dir()?
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize state
|
||||
eprintln!("Loading org files from: {}", org_dir.display());
|
||||
|
|
@ -111,11 +128,16 @@ USAGE:
|
|||
ork-server [OPTIONS] [ORG_DIR]
|
||||
|
||||
OPTIONS:
|
||||
-d, --dir <DIR> Directory containing org files (default: current dir)
|
||||
-d, --dir <DIR> Directory containing org files (default: config org_path, else current dir)
|
||||
-a, --addr <ADDR> Listen address (default: tcp://localhost:5640)
|
||||
Examples: tcp://0.0.0.0:5640, unix:///tmp/ork.sock
|
||||
-h, --help Print help
|
||||
|
||||
CONFIG:
|
||||
Reads $XDG_CONFIG_HOME/orkmode/config.toml (else ~/.config/orkmode/config.toml).
|
||||
Key: org_path = \"~/org\" (leading ~ expands to $HOME)
|
||||
Exposed over 9P as the root /cfg file (read raw, write to persist + apply).
|
||||
|
||||
EXAMPLES:
|
||||
ork-server ~/org
|
||||
ork-server -a unix:///tmp/ork.sock ~/org
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
//! On-disk server configuration.
|
||||
//!
|
||||
//! The server keeps a small TOML config file on physical storage (by default
|
||||
//! `$XDG_CONFIG_HOME/orkmode/config.toml`, falling back to
|
||||
//! `~/.config/orkmode/config.toml`). It is exposed over 9P as the root `/cfg`
|
||||
//! file: reading returns the raw file contents, writing persists them back to
|
||||
//! disk and applies any changed settings.
|
||||
//!
|
||||
//! The only setting so far is `org_path`, the directory the server scans for
|
||||
//! `.org` files. A leading `~` in that path is expanded to `$HOME`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Parsed configuration. Unknown keys are ignored on read; absent keys use
|
||||
/// their defaults.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Directory the server scans for org files. May contain a leading `~`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub org_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the config directory: `$XDG_CONFIG_HOME/orkmode` if set, else
|
||||
/// `$HOME/.config/orkmode`. Returns `None` if neither variable is set.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return Some(PathBuf::from(xdg).join("orkmode"));
|
||||
}
|
||||
}
|
||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/orkmode"))
|
||||
}
|
||||
|
||||
/// Full path to the config file (`<config_dir>/config.toml`).
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("config.toml"))
|
||||
}
|
||||
|
||||
/// Expand a leading `~` or `~/` in `path` to `$HOME`. Any other `~` (e.g.
|
||||
/// `~user`) is left untouched. Non-tilde paths are returned as-is.
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// Read the raw config file contents. Returns an empty string if the file (or
|
||||
/// its directory) does not exist, so a fresh install reads as empty rather than
|
||||
/// erroring.
|
||||
pub fn read_raw() -> String {
|
||||
match config_path() {
|
||||
Some(p) => std::fs::read_to_string(&p).unwrap_or_default(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and parse the config file. A missing file yields the default config; a
|
||||
/// malformed file is an error.
|
||||
pub fn load() -> Result<Config> {
|
||||
let raw = read_raw();
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(Config::default());
|
||||
}
|
||||
parse(&raw)
|
||||
}
|
||||
|
||||
/// Parse raw TOML into a `Config`, mapping parse errors to `io::Error`.
|
||||
pub fn parse(raw: &str) -> Result<Config> {
|
||||
toml::from_str(raw).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
|
||||
}
|
||||
|
||||
/// Persist raw config contents to the config file, creating the config
|
||||
/// directory if needed. The caller is responsible for validating the contents
|
||||
/// first (see [`parse`]).
|
||||
pub fn write_raw(content: &str) -> Result<()> {
|
||||
let path = config_path()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory (set HOME or XDG_CONFIG_HOME)"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_bare() {
|
||||
std::env::set_var("HOME", "/home/tester");
|
||||
assert_eq!(expand_tilde("~"), PathBuf::from("/home/tester"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_subpath() {
|
||||
std::env::set_var("HOME", "/home/tester");
|
||||
assert_eq!(expand_tilde("~/org/notes"), PathBuf::from("/home/tester/org/notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop() {
|
||||
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
|
||||
assert_eq!(expand_tilde("relative/path"), PathBuf::from("relative/path"));
|
||||
// ~user is not expanded.
|
||||
assert_eq!(expand_tilde("~user/x"), PathBuf::from("~user/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_org_path() {
|
||||
let cfg = parse("org_path = \"~/org\"\n").unwrap();
|
||||
assert_eq!(cfg.org_path.as_deref(), Some("~/org"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_is_default() {
|
||||
let cfg = load_from_str("").unwrap();
|
||||
assert!(cfg.org_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_errors() {
|
||||
assert!(parse("org_path = ").is_err());
|
||||
}
|
||||
|
||||
// Helper mirroring load() without touching the real filesystem.
|
||||
fn load_from_str(raw: &str) -> Result<Config> {
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(Config::default());
|
||||
}
|
||||
parse(raw)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ pub mod namespace;
|
|||
pub mod p9;
|
||||
pub mod sandbox;
|
||||
pub mod languages;
|
||||
pub mod config;
|
||||
|
||||
pub use state::OrkState;
|
||||
pub use p9::Server;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,24 @@ pub fn build_namespace(state: Arc<OrkState>) -> FsNode {
|
|||
}
|
||||
})),
|
||||
|
||||
// cfg - on-disk server configuration (TOML). Reading returns the
|
||||
// raw config file; writing validates, applies, and persists it.
|
||||
// The org_path setting switches the scanned org directory (with
|
||||
// leading `~` expanded to $HOME).
|
||||
virtfs::file("cfg", 0o666, virtfs::rw(
|
||||
{
|
||||
let state = state.clone();
|
||||
move || Ok(state.config_raw().into_bytes())
|
||||
},
|
||||
{
|
||||
let state = state.clone();
|
||||
move |data| {
|
||||
let raw = String::from_utf8_lossy(data);
|
||||
state.apply_config(&raw)
|
||||
}
|
||||
},
|
||||
)),
|
||||
|
||||
// idx - document index
|
||||
virtfs::file("idx", 0o444, virtfs::read({
|
||||
let state = state.clone();
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ pub struct DocState {
|
|||
|
||||
/// Server state: all loaded documents.
|
||||
pub struct OrkState {
|
||||
/// Directory containing org files.
|
||||
pub org_dir: PathBuf,
|
||||
/// Directory containing org files. Mutable at runtime via `/cfg`.
|
||||
org_dir: RwLock<PathBuf>,
|
||||
|
||||
/// Parsed documents by name (without .org extension).
|
||||
docs: RwLock<HashMap<String, Arc<DocState>>>,
|
||||
|
|
@ -89,7 +89,7 @@ impl OrkState {
|
|||
}
|
||||
|
||||
let state = Self {
|
||||
org_dir,
|
||||
org_dir: RwLock::new(org_dir),
|
||||
docs: RwLock::new(HashMap::new()),
|
||||
parser: RwLock::new(Parser::new()),
|
||||
languages: RwLock::new(languages),
|
||||
|
|
@ -99,12 +99,53 @@ impl OrkState {
|
|||
Ok(state)
|
||||
}
|
||||
|
||||
/// The directory currently scanned for org files.
|
||||
pub fn org_dir(&self) -> PathBuf {
|
||||
self.org_dir.read().clone()
|
||||
}
|
||||
|
||||
/// Switch the scanned directory and reload documents from it. Rejects a
|
||||
/// path that is not an existing directory, leaving the current state
|
||||
/// unchanged.
|
||||
pub fn set_org_dir(&self, dir: PathBuf) -> Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotADirectory,
|
||||
format!("not a directory: {}", dir.display()),
|
||||
));
|
||||
}
|
||||
*self.org_dir.write() = dir;
|
||||
self.scan()
|
||||
}
|
||||
|
||||
/// The raw on-disk config file contents, for the `/cfg` read handler.
|
||||
pub fn config_raw(&self) -> String {
|
||||
crate::config::read_raw()
|
||||
}
|
||||
|
||||
/// Apply a `/cfg` write: validate the TOML, apply any settings it changes,
|
||||
/// then persist it to disk. Validation and application happen before the
|
||||
/// write, so a rejected config never reaches disk and never changes state.
|
||||
///
|
||||
/// Currently the only applied setting is `org_path`: a leading `~` is
|
||||
/// expanded to `$HOME` and the server switches to and rescans that
|
||||
/// directory. A non-existent directory is rejected.
|
||||
pub fn apply_config(&self, raw: &str) -> Result<()> {
|
||||
let cfg = crate::config::parse(raw)?;
|
||||
if let Some(org_path) = cfg.org_path.as_deref() {
|
||||
let dir = crate::config::expand_tilde(org_path);
|
||||
self.set_org_dir(dir)?;
|
||||
}
|
||||
crate::config::write_raw(raw)
|
||||
}
|
||||
|
||||
/// Scan org directory for files.
|
||||
pub fn scan(&self) -> Result<()> {
|
||||
let org_dir = self.org_dir.read().clone();
|
||||
let mut docs = self.docs.write();
|
||||
docs.clear();
|
||||
|
||||
for entry in fs::read_dir(&self.org_dir)? {
|
||||
for entry in fs::read_dir(&org_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
|
|
@ -1492,7 +1533,7 @@ impl OrkState {
|
|||
.collect();
|
||||
|
||||
let filename = format!("{}.org", safe_name);
|
||||
let path = self.org_dir.join(&filename);
|
||||
let path = self.org_dir().join(&filename);
|
||||
|
||||
if path.exists() {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "file already exists"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue