Skip to main content

sioul_core/
routines.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Routines (docs/tasks.md, "Routines"): a sequence of timed steps played one
5//! at a time, the next one said before it comes, so nothing is decided while
6//! doing (Routinery). Moving on when a step's time is up is each routine's
7//! choice: it helps some and pressures others. Skipping costs nothing and is
8//! never counted. Kept in the configuration (`[[routine]]`), so they travel
9//! with your settings.
10
11use serde::{Deserialize, Serialize};
12
13/// One step: what, for how long, and what it opens (`porch`, `sioul:task/<UID>`).
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub struct Step {
16    pub title: String,
17    #[serde(default)]
18    pub minutes: u32,
19    #[serde(default, skip_serializing_if = "String::is_empty")]
20    pub open: String,
21}
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Routine {
25    pub id: String,
26    pub title: String,
27    /// The next step starts by itself when a step's time is up.
28    #[serde(default)]
29    pub auto: bool,
30    #[serde(default, rename = "step")]
31    pub steps: Vec<Step>,
32}
33
34/// "10 min Open the Porch", "Open the Porch 10", "Open the Porch ~10m", "Make tea (5)": one step a line.
35pub fn parse_steps(text: &str) -> Vec<Step> {
36    text.lines().map(str::trim).filter(|l| !l.is_empty()).map(parse_step).collect()
37}
38
39fn minutes_word(word: &str) -> Option<u32> {
40    let word = word.trim_matches(|c: char| c == '~' || c == '(' || c == ')' || c == ',');
41    let lower = word.to_lowercase();
42    let digits: String = lower.chars().take_while(char::is_ascii_digit).collect();
43    let rest = &lower[digits.len()..];
44    let number: u32 = digits.parse().ok()?;
45    match rest {
46        "" | "m" | "min" | "mn" | "minutes" | "minute" => Some(number),
47        "h" => number.checked_mul(60),
48        _ => None,
49    }
50}
51
52fn parse_step(line: &str) -> Step {
53    let words: Vec<&str> = line.split_whitespace().collect();
54    // At the start: "10 min …", "10min …", "~10m …".
55    if let Some(first) = words.first().and_then(|w| minutes_word(w)) {
56        let skip = if words.get(1).is_some_and(|w| matches!(w.to_lowercase().as_str(), "min" | "mn" | "minutes" | "minute")) { 2 } else { 1 };
57        let title = words[skip.min(words.len())..].join(" ");
58        if !title.is_empty() {
59            return Step { title, minutes: first, open: String::new() };
60        }
61    }
62    // At the end: "… 10", "… 10 min", "… ~10m", "… (5)".
63    let n = words.len();
64    if n >= 2 && matches!(words[n - 1].to_lowercase().as_str(), "min" | "mn" | "minutes" | "minute")
65        && let Some(minutes) = minutes_word(words[n - 2])
66    {
67        return Step { title: words[..n - 2].join(" "), minutes, open: String::new() };
68    }
69    if n >= 2
70        && let Some(minutes) = minutes_word(words[n - 1])
71    {
72        return Step { title: words[..n - 1].join(" "), minutes, open: String::new() };
73    }
74    Step { title: line.to_string(), minutes: 5, open: String::new() }
75}
76
77/// The steps as one line each, as the form shows them: "10 min Open the Porch".
78pub fn steps_text(steps: &[Step]) -> String {
79    steps.iter().map(|s| format!("{} min {}", s.minutes, s.title)).collect::<Vec<_>>().join("\n")
80}
81
82/// The routines as the configuration holds them now, changed by `change` and
83/// written back, the whole under the file's lock, which the sharing takes
84/// too: a routine another device's change brought in meanwhile is never
85/// written over with an older list.
86pub fn change(config_path: &std::path::Path, change: impl FnOnce(&mut Vec<Routine>)) -> Result<(), String> {
87    #[derive(Deserialize, Default)]
88    struct Listed {
89        #[serde(default)]
90        routine: Vec<Routine>,
91    }
92    crate::filelock::with_lock(config_path, || {
93        let fail = |e: String| format!("{}: {e}", config_path.display());
94        let text = match std::fs::read_to_string(config_path) {
95            Ok(text) => text,
96            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
97            Err(e) => return Err(fail(e.to_string())),
98        };
99        let mut routines = toml::from_str::<Listed>(&text).map_err(|e| fail(e.to_string()))?.routine;
100        change(&mut routines);
101        save(config_path, &routines)
102    })
103}
104
105/// The routines written back into the configuration, the rest of it as
106/// written, read and written under the file's lock.
107pub fn save(config_path: &std::path::Path, routines: &[Routine]) -> Result<(), String> {
108    crate::filelock::with_lock(config_path, || save_locked(config_path, routines))
109}
110
111fn save_locked(config_path: &std::path::Path, routines: &[Routine]) -> Result<(), String> {
112    let fail = |e: String| format!("{}: {e}", config_path.display());
113    // No configuration yet: it is made. One there but unreadable (its rights, an
114    // encoding other than UTF-8) or not TOML is left alone: written over, every setting would go.
115    let text = match std::fs::read_to_string(config_path) {
116        Ok(text) => text,
117        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
118        Err(e) => return Err(fail(e.to_string())),
119    };
120    let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
121    doc.remove("routine");
122    if !routines.is_empty() {
123        let mut list = toml_edit::ArrayOfTables::new();
124        for routine in routines {
125            let mut table = toml_edit::Table::new();
126            table["id"] = toml_edit::value(&routine.id);
127            table["title"] = toml_edit::value(&routine.title);
128            if routine.auto {
129                table["auto"] = toml_edit::value(true);
130            }
131            let mut steps = toml_edit::ArrayOfTables::new();
132            for step in &routine.steps {
133                let mut s = toml_edit::Table::new();
134                s["title"] = toml_edit::value(&step.title);
135                s["minutes"] = toml_edit::value(i64::from(step.minutes));
136                if !step.open.is_empty() {
137                    s["open"] = toml_edit::value(&step.open);
138                }
139                steps.push(s);
140            }
141            table.insert("step", toml_edit::Item::ArrayOfTables(steps));
142            list.push(table);
143        }
144        doc.insert("routine", toml_edit::Item::ArrayOfTables(list));
145    }
146    let temporary = config_path.with_extension("toml.new");
147    std::fs::write(&temporary, doc.to_string()).and_then(|()| std::fs::rename(&temporary, config_path)).map_err(|e| format!("{}: {e}", config_path.display()))
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn steps_read_from_lines() {
156        let steps = parse_steps("10 min Open the Porch\nAnswer the two cards 20\nFile ~5m\nMake tea (3)\n\n2h Deep clean\nJust this");
157        let pairs: Vec<(&str, u32)> = steps.iter().map(|s| (s.title.as_str(), s.minutes)).collect();
158        assert_eq!(pairs, vec![("Open the Porch", 10), ("Answer the two cards", 20), ("File", 5), ("Make tea", 3), ("Deep clean", 120), ("Just this", 5)]);
159        assert_eq!(parse_steps(&steps_text(&steps)), steps);
160    }
161
162    #[test]
163    fn saved_in_the_configuration() {
164        let dir = std::env::temp_dir().join(format!("sioul-routines-{}", std::process::id()));
165        std::fs::create_dir_all(&dir).unwrap();
166        let path = dir.join("config.toml");
167        std::fs::write(&path, "# mine\nlanguage = \"fr\"\n").unwrap();
168        let routine = Routine { id: "morning".into(), title: "Morning".into(), auto: true, steps: parse_steps("5 min Water\n10 min Stretch") };
169        save(&path, std::slice::from_ref(&routine)).unwrap();
170        let text = std::fs::read_to_string(&path).unwrap();
171        assert!(text.starts_with("# mine\nlanguage = \"fr\""), "{text}");
172        let config: crate::config::Config = toml::from_str(&text).unwrap();
173        assert_eq!(config.routines, vec![routine.clone()]);
174        save(&path, &[]).unwrap();
175        assert!(!std::fs::read_to_string(&path).unwrap().contains("routine"));
176        // A configuration that cannot be read (here, not UTF-8) or is not TOML is never written over.
177        for broken in [b"# mine\nlanguage = \"fran\xe7ais\"\n".to_vec(), b"language = = \"fr\"\n".to_vec()] {
178            std::fs::write(&path, &broken).unwrap();
179            assert!(save(&path, std::slice::from_ref(&routine)).is_err());
180            assert_eq!(std::fs::read(&path).unwrap(), broken);
181        }
182        assert_eq!(parse_steps("Rest 99999999h"), vec![Step { title: "Rest 99999999h".into(), minutes: 5, open: String::new() }], "too long: no time read");
183        let _ = std::fs::remove_dir_all(&dir);
184    }
185}