src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md15
-rw-r--r--src/config.rs143
-rw-r--r--src/main.rs24
-rw-r--r--src/output.rs2
4 files changed, 167 insertions, 17 deletions
diff --git a/README.md b/README.md
index a20766a..5cd8c95 100644
--- a/README.md
+++ b/README.md
@@ -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 {