Skip to main content

liboswo/
cfg.rs

1use color_eyre::{eyre::Context, Result};
2use log::info;
3use serde::{Deserialize, Serialize};
4use std::{
5    collections::HashMap,
6    ops::Deref,
7    path::{Path, PathBuf},
8};
9
10use crate::Outputs;
11
12#[derive(Debug, Deserialize)]
13pub struct Cfgs(HashMap<String, Config>);
14
15#[derive(Debug, Deserialize, Serialize, Clone)]
16pub struct DesiredOutput {
17    pub name: String,
18    pub scale: Option<f64>,
19}
20
21/// Config describes a named configuration: outputs plus optional priority
22#[derive(Debug, Deserialize, Serialize, Clone)]
23pub struct Config {
24    pub outputs: Vec<DesiredOutput>,
25    /// higher number -> higher priority; optional for backwards compatibility
26    pub priority: Option<i64>,
27}
28
29impl Deref for Cfgs {
30    type Target = HashMap<String, Config>;
31
32    fn deref(&self) -> &Self::Target {
33        &self.0
34    }
35}
36
37// TODO: allow name + scale and name only
38// #[derive(Debug, Deserialize)]
39// enum OutputVariants {
40//     Full(DesiredOutput),
41//     Name(String),
42// }
43
44impl TryFrom<&toml_edit::Table> for Cfgs {
45    type Error = color_eyre::Report;
46
47    fn try_from(table: &toml_edit::Table) -> std::result::Result<Self, Self::Error> {
48        let cfg: Result<HashMap<String, Config>> = table
49            .into_iter()
50            .map(|(name, inner)| {
51                let section_str = inner
52                    .as_table()
53                    .map(|t| t.to_string())
54                    .unwrap_or(inner.as_str().unwrap_or("").to_string());
55                let cfg_entry: Config =
56                    toml_edit::de::from_str(&section_str).wrap_err_with(|| {
57                        format!(
58                            "Missing outputs in configuration {}: {}",
59                            &name,
60                            &inner.to_string(),
61                        )
62                    })?;
63                let name = name.to_string();
64                Ok((name, cfg_entry))
65            })
66            .collect();
67        Ok(Cfgs(cfg?))
68    }
69}
70
71impl Cfgs {
72    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
73        let cfg_str = std::fs::read_to_string(&path)
74            .wrap_err_with(|| format!("Failed to read {}", path.as_ref().display()))?;
75        let cfgs_doc: toml_edit::Document = cfg_str
76            .parse()
77            .wrap_err("Failed to parse configurtion file")?;
78        let cfgs = cfgs_doc.as_table();
79        Self::try_from(cfgs)
80    }
81
82    /// Return the Config for a named configuration (if present)
83    pub fn find(&self, key: &str) -> Option<&Config> {
84        self.0.get(key)
85    }
86
87    pub fn default_path() -> PathBuf {
88        dirs::config_dir()
89            .unwrap_or("/etc/xdg/".into())
90            .join("oswo.toml")
91    }
92
93    pub fn add(&mut self, name: &str, outputs: &Outputs) -> Result<()> {
94        let active_outputs: Vec<_> = outputs
95            .iter()
96            .filter(|o| o.enabled())
97            .map(|o| DesiredOutput {
98                name: o.name().to_string(),
99                scale: Some(o.scale()),
100            })
101            .collect();
102
103        match self.0.insert(
104            name.to_string(),
105            Config {
106                outputs: active_outputs,
107                priority: None,
108            },
109        ) {
110            Some(_) => info!("Updated config {name}"),
111            None => info!("Added new config {name}"),
112        }
113        Ok(())
114    }
115
116    /// Save the current configurations to the given path, overwriting the file.
117    /// This is a simple overwrite: it serializes the internal HashMap to TOML and writes
118    /// it atomically by writing to a temporary file then renaming.
119    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
120        let path = path.as_ref();
121
122        // Read existing document if present, otherwise start a new one.
123        let mut doc = if path.exists() {
124            let s = std::fs::read_to_string(path)
125                .wrap_err_with(|| format!("Failed to read {}", path.display()))?;
126            s.parse::<toml_edit::Document>()
127                .wrap_err("Failed to parse existing TOML file")?
128        } else {
129            toml_edit::Document::new()
130        };
131
132        // For each config, build a table with outputs (array of inline tables) and optional priority,
133        // then insert/replace into the document. This preserves other top-level content and comments.
134        for (name, cfg) in &self.0 {
135            let mut section = toml_edit::Table::new();
136
137            // Build outputs array with inline tables
138            let mut outputs_array = toml_edit::Array::new();
139            for output in &cfg.outputs {
140                let mut output_table = toml_edit::InlineTable::new();
141                output_table.insert("name", output.name.clone().into());
142                if let Some(scale) = output.scale {
143                    output_table.insert("scale", scale.into());
144                }
145                outputs_array.push(output_table);
146            }
147            section["outputs"] = toml_edit::Item::Value(toml_edit::Value::Array(outputs_array));
148
149            if let Some(p) = cfg.priority {
150                section["priority"] = toml_edit::value(p);
151            } else {
152                // Ensure no stray priority remains if previously present: leave absent.
153            }
154
155            doc[name.as_str()] = toml_edit::Item::Table(section);
156        }
157
158        // Write atomically: write to tmp file then rename
159        let tmp = path.with_extension("tmp");
160        std::fs::write(&tmp, doc.to_string())
161            .wrap_err_with(|| format!("Failed to write temp file {}", tmp.display()))?;
162        std::fs::rename(&tmp, path).wrap_err_with(|| {
163            format!("Failed to rename {} -> {}", tmp.display(), path.display())
164        })?;
165
166        Ok(())
167    }
168}
169
170impl std::fmt::Display for Cfgs {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        self.0.iter().try_fold((), |_, (name, cfg)| {
173            let setup_str = cfg
174                .outputs
175                .iter()
176                .map(|o| format!("{}", o))
177                .collect::<Vec<_>>()
178                .join("\n  ");
179            let priority_str = cfg
180                .priority
181                .map(|p| format!(" (priority: {})", p))
182                .unwrap_or_default();
183            write!(f, "{}{}:\n  {}\n", name, priority_str, setup_str)
184        })
185    }
186}
187
188impl std::fmt::Display for DesiredOutput {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        write!(f, "{} (scale: {})", self.name, self.scale.unwrap_or(1.0))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn parse_priority() {
200        let s = r#"
201        [a]
202        outputs = [{ name = "Foo", scale = 1.0 }]
203        priority = 5
204        "#;
205        let doc: toml_edit::Document = s.parse().unwrap();
206        let cfgs = Cfgs::try_from(doc.as_table()).unwrap();
207        let cfg = cfgs.find("a").expect("config 'a' present");
208        assert_eq!(cfg.priority.unwrap(), 5);
209        assert_eq!(cfg.outputs.len(), 1);
210        assert_eq!(cfg.outputs[0].name, "Foo");
211    }
212}