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:
Levi Neely 2026-10-02 12:07:52 +02:00
parent b385429d1b
commit 7c9779b2fc
5 changed files with 232 additions and 9 deletions

View File

@ -13,7 +13,7 @@ fn main() -> io::Result<()> {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
// Parse arguments // 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 addr = "tcp://localhost:5640".to_string();
let mut i = 1; let mut i = 1;
@ -22,7 +22,7 @@ fn main() -> io::Result<()> {
"-d" | "--dir" => { "-d" | "--dir" => {
i += 1; i += 1;
if i < args.len() { if i < args.len() {
org_dir = PathBuf::from(&args[i]); org_dir = Some(PathBuf::from(&args[i]));
} }
} }
"-a" | "--addr" => { "-a" | "--addr" => {
@ -37,11 +37,28 @@ fn main() -> io::Result<()> {
} }
_ => { _ => {
// Assume it's the org directory // Assume it's the org directory
org_dir = PathBuf::from(&args[i]); org_dir = Some(PathBuf::from(&args[i]));
} }
} }
i += 1; 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 // Initialize state
eprintln!("Loading org files from: {}", org_dir.display()); eprintln!("Loading org files from: {}", org_dir.display());
@ -111,11 +128,16 @@ USAGE:
ork-server [OPTIONS] [ORG_DIR] ork-server [OPTIONS] [ORG_DIR]
OPTIONS: 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) -a, --addr <ADDR> Listen address (default: tcp://localhost:5640)
Examples: tcp://0.0.0.0:5640, unix:///tmp/ork.sock Examples: tcp://0.0.0.0:5640, unix:///tmp/ork.sock
-h, --help Print help -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: EXAMPLES:
ork-server ~/org ork-server ~/org
ork-server -a unix:///tmp/ork.sock ~/org ork-server -a unix:///tmp/ork.sock ~/org

View File

@ -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)
}
}

View File

@ -8,6 +8,7 @@ pub mod namespace;
pub mod p9; pub mod p9;
pub mod sandbox; pub mod sandbox;
pub mod languages; pub mod languages;
pub mod config;
pub use state::OrkState; pub use state::OrkState;
pub use p9::Server; pub use p9::Server;

View File

@ -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 // idx - document index
virtfs::file("idx", 0o444, virtfs::read({ virtfs::file("idx", 0o444, virtfs::read({
let state = state.clone(); let state = state.clone();

View File

@ -61,8 +61,8 @@ pub struct DocState {
/// Server state: all loaded documents. /// Server state: all loaded documents.
pub struct OrkState { pub struct OrkState {
/// Directory containing org files. /// Directory containing org files. Mutable at runtime via `/cfg`.
pub org_dir: PathBuf, org_dir: RwLock<PathBuf>,
/// Parsed documents by name (without .org extension). /// Parsed documents by name (without .org extension).
docs: RwLock<HashMap<String, Arc<DocState>>>, docs: RwLock<HashMap<String, Arc<DocState>>>,
@ -89,7 +89,7 @@ impl OrkState {
} }
let state = Self { let state = Self {
org_dir, org_dir: RwLock::new(org_dir),
docs: RwLock::new(HashMap::new()), docs: RwLock::new(HashMap::new()),
parser: RwLock::new(Parser::new()), parser: RwLock::new(Parser::new()),
languages: RwLock::new(languages), languages: RwLock::new(languages),
@ -99,12 +99,53 @@ impl OrkState {
Ok(state) 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. /// Scan org directory for files.
pub fn scan(&self) -> Result<()> { pub fn scan(&self) -> Result<()> {
let org_dir = self.org_dir.read().clone();
let mut docs = self.docs.write(); let mut docs = self.docs.write();
docs.clear(); docs.clear();
for entry in fs::read_dir(&self.org_dir)? { for entry in fs::read_dir(&org_dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
@ -1492,7 +1533,7 @@ impl OrkState {
.collect(); .collect();
let filename = format!("{}.org", safe_name); let filename = format!("{}.org", safe_name);
let path = self.org_dir.join(&filename); let path = self.org_dir().join(&filename);
if path.exists() { if path.exists() {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "file already exists")); return Err(io::Error::new(io::ErrorKind::AlreadyExists, "file already exists"));