diff options
| author | Luke Hoersten <[email protected]> | 2026-07-29 10:33:02 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-29 10:33:02 -0500 |
| commit | 78772329dc625b76bc1cbca61e0ed4912e93e0c8 (patch) | |
| tree | 70e7cd62c241b2227402dc376f19350dccc5ae6c | |
| parent | 5c81b970819e8d38413a5dc02d56b7f182715e8a (diff) | |
- timezone optional -> host system zone (jiff, macOS + Linux)
- storagePath optional -> platform data dir (/var/lib on Linux, ~/Library/Application Support on macOS)
- config file optional at the default path; explicit --config must still exist
- default logLevel changed from info to warn
- new output field (text|json) sets the default rendering so -j isn't needed per call
| -rw-r--r-- | README.md | 15 | ||||
| -rw-r--r-- | src/config.rs | 143 | ||||
| -rw-r--r-- | src/main.rs | 24 | ||||
| -rw-r--r-- | src/output.rs | 2 |
4 files changed, 167 insertions, 17 deletions
@@ -28,7 +28,7 @@ There is no daemon: each invocation builds the Matter stack, connects, syncs, an | Option | Description | | --- | --- | -| `-c, --config <path>` | Config file. Default `/etc/mattertimectl/config.json`. | +| `-c, --config <path>` | Config file. Defaults to `/etc/mattertimectl/config.json` if present; optional there, but a path given explicitly must exist. | | `-n, --node <id>` | Target one device. `sync` and `decommission` default to all commissioned devices. | | `-t, --time <hh:mm>` | `sync` only: set this wall-clock time today (e.g. `16:35`, `4:35pm`) instead of now. The operator becomes the time source and the NTP gate is skipped. | | `-j, --json` | Machine-readable JSON on stdout (64-bit values as decimal strings). Logs stay on stderr, so `--json` is always clean. | @@ -82,12 +82,19 @@ config error. } ``` +Every field is optional. `storagePath` defaults to `/var/lib/mattertimectl` on Linux and +`~/Library/Application Support/mattertimectl` on macOS; `timezone` defaults to the host's system +zone; `logLevel` defaults to `warn`. So a minimal config file is just `{}` — and the file itself is +optional at the default location: with no `/etc/mattertimectl/config.json`, mattertimectl runs +entirely on defaults. A path passed with `--config` must exist. + | Field | Default | Meaning | | ------------- | ------------------ | --------------------------------------------------------------- | -| `storagePath` | (required) | Directory for persistent Matter fabric state. Contains secrets. | -| `timezone` | `America/Chicago` | IANA time-zone name. Never a fixed UTC offset; DST is derived. | -| `logLevel` | `info` | `debug`, `info`, `warn`, or `error`. | +| `storagePath` | platform data dir | Directory for persistent Matter fabric state (mode 0700). Contains secrets. | +| `timezone` | system zone | IANA time-zone name. Never a fixed UTC offset; DST is derived. | +| `logLevel` | `warn` | `debug`, `info`, `warn`, or `error`. | | `fabricLabel` | `Matter Time Controller` | Label other ecosystems show for this controller (max 32 chars). | +| `output` | `text` | Default output format, `text` or `json`. `json` makes every command emit JSON without a per-call `-j` (which still forces JSON). | Unknown fields are rejected loudly. Matter requires fabric labels to be unique per device and Apple Home labels its own entry with the home's name, so use a variant like "Lakeside Time Sync" rather diff --git a/src/config.rs b/src/config.rs index 43059f9..839ca2d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,8 +40,8 @@ pub enum ConfigError { #[serde(rename_all = "lowercase")] pub enum LogLevel { Debug, - #[default] Info, + #[default] Warn, Error, } @@ -69,6 +69,24 @@ impl fmt::Display for LogLevel { } } +/// Default rendering for commands not given `--json`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OutputFormat { + #[default] + Text, + Json, +} + +impl fmt::Display for OutputFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + OutputFormat::Text => "text", + OutputFormat::Json => "json", + }) + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Config { @@ -76,9 +94,12 @@ pub struct Config { #[serde(skip)] pub source: PathBuf, /// Directory holding persistent Matter fabric state and service state. - /// Contains the controller's private keys; mode 0700. + /// Contains the controller's private keys; mode 0700. Defaults to a + /// platform-appropriate data directory when omitted. + #[serde(default = "default_storage_path")] pub storage_path: PathBuf, /// IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset. + /// Defaults to the host's system time zone when omitted. #[serde(default = "default_timezone")] pub timezone: String, #[serde(default)] @@ -87,16 +108,54 @@ pub struct Config { /// Apple Home Connected Services subtitle). Must be unique per device. #[serde(default = "default_fabric_label")] pub fabric_label: String, + /// Default output format for commands not given `--json`. `text` (the + /// default) prints the human-readable rendering; `json` makes every + /// command emit JSON without a per-call `-j`. `-j` still forces JSON. + #[serde(default)] + pub output: OutputFormat, } +/// The host's IANA time-zone name, used when the config omits `timezone`. +/// jiff reads the system zone (`/etc/localtime`, `TZ`) on both Linux and +/// macOS; falls back to UTC if the zone cannot be identified. fn default_timezone() -> String { - "America/Chicago".into() + TimeZone::try_system() + .ok() + .and_then(|tz| tz.iana_name().map(str::to_string)) + .unwrap_or_else(|| "UTC".to_string()) +} + +/// Platform-appropriate data directory, used when the config omits +/// `storagePath`. Linux follows the FHS service convention; macOS uses the +/// per-user Application Support directory, since there is no `/var/lib` there. +fn default_storage_path() -> PathBuf { + #[cfg(target_os = "macos")] + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join("Library/Application Support/mattertimectl"); + } + PathBuf::from("/var/lib/mattertimectl") } fn default_fabric_label() -> String { "Matter Time Controller".into() } +impl Default for Config { + /// The all-defaults configuration: platform data directory, system time + /// zone, warn logging, default fabric label. Kept in step with the serde + /// field defaults so an empty file and an absent file behave identically. + fn default() -> Self { + Config { + source: PathBuf::new(), + storage_path: default_storage_path(), + timezone: default_timezone(), + log_level: LogLevel::default(), + fabric_label: default_fabric_label(), + output: OutputFormat::default(), + } + } +} + impl Config { pub fn load(path: &std::path::Path) -> Result<Self, ConfigError> { let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Unreadable { @@ -108,6 +167,28 @@ impl Config { Ok(config) } + /// Like [`load`](Self::load), but a missing file is not an error: it + /// resolves to the built-in defaults. Used for the default config + /// location, which is optional; an explicitly requested file that is + /// absent is still an error via [`load`](Self::load). + pub fn load_optional(path: &std::path::Path) -> Result<Self, ConfigError> { + match std::fs::read_to_string(path) { + Ok(raw) => { + let mut config = Self::parse(&raw)?; + config.source = path.to_owned(); + Ok(config) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self { + source: path.to_owned(), + ..Self::default() + }), + Err(source) => Err(ConfigError::Unreadable { + path: path.to_owned(), + source, + }), + } + } + pub fn parse(raw: &str) -> Result<Self, ConfigError> { let config: Config = serde_json::from_str(raw)?; config.validate()?; @@ -156,6 +237,12 @@ impl Config { .collect() } + /// Whether commands should emit JSON by default (config `output: "json"`), + /// without a per-command `-j`. A `-j` flag still forces JSON on top of this. + pub fn json_output(&self) -> bool { + matches!(self.output, OutputFormat::Json) + } + /// The configured zone, resolved against the system tzdb. pub fn time_zone(&self) -> TimeZone { // Validated at load time; a tzdb that shrinks between then and now is @@ -177,9 +264,21 @@ mod tests { config.storage_path, PathBuf::from("/var/lib/mattertimectl") ); - assert_eq!(config.timezone, "America/Chicago"); - assert_eq!(config.log_level, LogLevel::Info); + assert_eq!(config.timezone, default_timezone()); + assert_eq!(config.log_level, LogLevel::Warn); assert_eq!(config.fabric_label, "Matter Time Controller"); + assert_eq!(config.output, OutputFormat::Text); + assert!(!config.json_output()); + } + + #[test] + fn output_format_parses_and_rejects_unknown() { + assert_eq!(Config::parse(r#"{}"#).unwrap().output, OutputFormat::Text); + let cfg = Config::parse(r#"{ "output": "json" }"#).unwrap(); + assert_eq!(cfg.output, OutputFormat::Json); + assert!(cfg.json_output()); + // The value is "text"/"json", not "human"/"plain"/etc. + assert!(Config::parse(r#"{ "output": "human" }"#).is_err()); } #[test] @@ -237,8 +336,16 @@ mod tests { } #[test] - fn storage_path_is_required() { - assert!(Config::parse(r#"{}"#).is_err()); + fn empty_config_uses_platform_defaults() { + // Every field is optional: an empty object resolves to the platform + // data directory, the host time zone, and the built-in defaults. + let config = Config::parse(r#"{}"#).unwrap(); + assert_eq!(config.storage_path, default_storage_path()); + assert!(config.storage_path.is_absolute()); + assert_eq!(config.timezone, default_timezone()); + assert!(TimeZone::get(&config.timezone).is_ok()); + assert_eq!(config.log_level, LogLevel::Warn); + assert_eq!(config.fabric_label, "Matter Time Controller"); } #[test] @@ -250,6 +357,28 @@ mod tests { } #[test] + fn missing_config_at_default_path_uses_defaults() { + let cfg = Config::load_optional(std::path::Path::new( + "/nonexistent/mattertimectl/does-not-exist.json", + )) + .unwrap(); + assert_eq!(cfg.storage_path, default_storage_path()); + assert_eq!(cfg.timezone, default_timezone()); + assert_eq!(cfg.log_level, LogLevel::Warn); + assert_eq!(cfg.fabric_label, "Matter Time Controller"); + } + + #[test] + fn explicitly_requested_missing_config_errors() { + assert!( + Config::load(std::path::Path::new( + "/nonexistent/mattertimectl/does-not-exist.json" + )) + .is_err() + ); + } + + #[test] fn invalid_log_levels_are_rejected() { assert!(Config::parse(r#"{ "storagePath": "/x", "logLevel": "verbose" }"#).is_err()); } diff --git a/src/main.rs b/src/main.rs index d245578..1eaf7e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,7 +41,7 @@ mod state; mod time; mod tz; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use clap::{Parser, Subcommand}; @@ -60,9 +60,11 @@ use crate::time::MatterMicros; disable_help_subcommand = true )] struct Cli { - /// Configuration file - #[arg(short = 'c', long, global = true, default_value = DEFAULT_CONFIG_PATH)] - config: PathBuf, + /// Configuration file. Every field is optional; at the default location + /// (/etc/mattertimectl/config.json) a missing file uses built-in defaults, + /// but a path given here explicitly must exist. + #[arg(short = 'c', long, global = true)] + config: Option<PathBuf>, #[command(subcommand)] command: Option<Command>, } @@ -134,7 +136,14 @@ fn main() -> ExitCode { return ExitCode::from(2); }; - let config = match Config::load(&cli.config) { + let loaded = match cli.config.as_deref() { + // An explicit --config path is a promise the file exists; a missing + // one is a mistake worth surfacing, not silently defaulting past. + Some(path) => Config::load(path), + // The default location is optional: with no file, run on defaults. + None => Config::load_optional(Path::new(DEFAULT_CONFIG_PATH)), + }; + let config = match loaded { Ok(config) => config, Err(error) => { eprintln!("Configuration error: {error}"); @@ -151,7 +160,7 @@ fn main() -> ExitCode { log::warn!("{warning}"); } - let (json, result) = match command { + let (json_flag, result) = match command { Command::Status { json, node } => (json, run_status(&config, Filter(node))), Command::Inspect { json, node } => (json, run_inspect(&config, Target(node))), Command::Sync { json, node, time } => { @@ -162,6 +171,8 @@ fn main() -> ExitCode { } Command::Decommission { json, node } => (json, run_decommission(&config, Target(node))), }; + // `-j` forces JSON; otherwise the config's `output` setting decides. + let json = json_flag || config.json_output(); let output = result.unwrap_or_else(|error| { log::error!("{error:#}"); @@ -266,6 +277,7 @@ fn run_status(config: &Config, filter: Filter) -> anyhow::Result<Output> { timezone: config.timezone.clone(), log_level: config.log_level.to_string(), fabric_label: config.fabric_label.clone(), + output: config.output.to_string(), }, storage_initialized: config.storage_path.is_dir(), controller_identity: identity, diff --git a/src/output.rs b/src/output.rs index 0a8a536..177e0f4 100644 --- a/src/output.rs +++ b/src/output.rs @@ -80,6 +80,7 @@ pub struct ConfigReport { pub timezone: String, pub log_level: String, pub fabric_label: String, + pub output: String, } #[derive(Debug, Serialize)] @@ -246,6 +247,7 @@ fn render_status(report: &StatusReport) { println!(" timezone: {}", report.config.timezone); println!(" logLevel: {}", report.config.log_level); println!(" fabricLabel: {}", report.config.fabric_label); + println!(" output: {}", report.config.output); println!( "Controller storage: {}", if report.storage_initialized { |
