diff --git a/crates/ork-server/src/bin/ork-server.rs b/crates/ork-server/src/bin/ork-server.rs index de4dd63..0fa0ed3 100644 --- a/crates/ork-server/src/bin/ork-server.rs +++ b/crates/ork-server/src/bin/ork-server.rs @@ -13,7 +13,7 @@ fn main() -> io::Result<()> { let args: Vec = env::args().collect(); // Parse arguments - let mut org_dir = env::current_dir()?; + let mut org_dir: Option = 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 Directory containing org files (default: current dir) + -d, --dir Directory containing org files (default: config org_path, else current dir) -a, --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 diff --git a/crates/ork-server/src/config.rs b/crates/ork-server/src/config.rs new file mode 100644 index 0000000..3ff332e --- /dev/null +++ b/crates/ork-server/src/config.rs @@ -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, +} + +/// 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 { + 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.toml`). +pub fn config_path() -> Option { + 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 { + 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 { + 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 { + if raw.trim().is_empty() { + return Ok(Config::default()); + } + parse(raw) + } +} diff --git a/crates/ork-server/src/lib.rs b/crates/ork-server/src/lib.rs index 8598f5c..b46902f 100644 --- a/crates/ork-server/src/lib.rs +++ b/crates/ork-server/src/lib.rs @@ -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; diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs index 8ae77ad..9e153cc 100644 --- a/crates/ork-server/src/namespace.rs +++ b/crates/ork-server/src/namespace.rs @@ -22,6 +22,24 @@ pub fn build_namespace(state: Arc) -> 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(); diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index a4b193a..1942098 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -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, /// Parsed documents by name (without .org extension). docs: RwLock>>, @@ -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"));