nixfleet_cli/
config.rs

1//! Operator-side config: layered resolution + TOML round-trip.
2//!
3//! Precedence (high -> low): explicit flags > NIXFLEET_* env > config file.
4//! When every layer leaves a field empty the resolver returns
5//! `ConfigError::Missing { field }` so the bin can render a useful hint.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct FileConfig {
13    pub cp_url: Option<String>,
14    pub ca_cert: Option<PathBuf>,
15    pub client_cert: Option<PathBuf>,
16    pub client_key: Option<PathBuf>,
17}
18
19impl FileConfig {
20    pub fn save(&self, path: &Path) -> std::io::Result<()> {
21        if let Some(parent) = path.parent() {
22            std::fs::create_dir_all(parent)?;
23        }
24        let body = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
25        // Write 0600 - config holds paths to private key material.
26        #[cfg(unix)]
27        {
28            use std::os::unix::fs::OpenOptionsExt;
29            let mut f = std::fs::OpenOptions::new()
30                .create(true)
31                .write(true)
32                .truncate(true)
33                .mode(0o600)
34                .open(path)?;
35            std::io::Write::write_all(&mut f, body.as_bytes())?;
36        }
37        #[cfg(not(unix))]
38        {
39            std::fs::write(path, body)?;
40        }
41        Ok(())
42    }
43}
44
45#[derive(Debug, Default, Clone)]
46pub struct Overrides {
47    pub cp_url: Option<String>,
48    pub ca_cert: Option<PathBuf>,
49    pub client_cert: Option<PathBuf>,
50    pub client_key: Option<PathBuf>,
51}
52
53#[derive(Debug)]
54pub enum ConfigError {
55    Read(std::io::Error),
56    Parse(toml::de::Error),
57    Missing { field: &'static str },
58}
59
60impl std::fmt::Display for ConfigError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::Read(e) => write!(f, "read config: {e}"),
64            Self::Parse(e) => write!(f, "parse config: {e}"),
65            Self::Missing { field } => write!(
66                f,
67                "no {field} in flags, env, or config file. \
68                 Run `nixfleet config init` to create one, or pass --{flag} / set NIXFLEET_{env}.",
69                field = field,
70                flag = field.replace('_', "-"),
71                env = field.to_uppercase(),
72            ),
73        }
74    }
75}
76
77impl std::error::Error for ConfigError {}
78
79pub fn load_file(path: &Path) -> Result<FileConfig, ConfigError> {
80    let body = std::fs::read_to_string(path).map_err(ConfigError::Read)?;
81    toml::from_str(&body).map_err(ConfigError::Parse)
82}
83
84pub fn resolve(
85    file_path: Option<&Path>,
86    env: &Overrides,
87    flags: &Overrides,
88) -> Result<crate::ResolvedClientConfig, ConfigError> {
89    let file = match file_path {
90        Some(p) => match load_file(p) {
91            Ok(c) => c,
92            Err(ConfigError::Read(e)) if e.kind() == std::io::ErrorKind::NotFound => {
93                FileConfig::default()
94            }
95            Err(other) => return Err(other),
96        },
97        None => FileConfig::default(),
98    };
99    let cp_url = flags
100        .cp_url
101        .clone()
102        .or_else(|| env.cp_url.clone())
103        .or(file.cp_url)
104        .ok_or(ConfigError::Missing { field: "cp_url" })?;
105    let ca_cert = flags
106        .ca_cert
107        .clone()
108        .or_else(|| env.ca_cert.clone())
109        .or(file.ca_cert)
110        .ok_or(ConfigError::Missing { field: "ca_cert" })?;
111    let client_cert = flags
112        .client_cert
113        .clone()
114        .or_else(|| env.client_cert.clone())
115        .or(file.client_cert)
116        .ok_or(ConfigError::Missing {
117            field: "client_cert",
118        })?;
119    let client_key = flags
120        .client_key
121        .clone()
122        .or_else(|| env.client_key.clone())
123        .or(file.client_key)
124        .ok_or(ConfigError::Missing {
125            field: "client_key",
126        })?;
127    Ok(crate::ResolvedClientConfig {
128        cp_url,
129        ca_cert,
130        client_cert,
131        client_key,
132    })
133}
134
135pub fn default_config_path() -> PathBuf {
136    if let Some(base) = dirs::config_dir() {
137        return base.join("nixfleet").join("config.toml");
138    }
139    PathBuf::from(".nixfleet/config.toml")
140}