1use serde::{Deserialize, Serialize};
12
13#[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 #[serde(default)]
29 pub auto: bool,
30 #[serde(default, rename = "step")]
31 pub steps: Vec<Step>,
32}
33
34pub 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 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 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
77pub fn steps_text(steps: &[Step]) -> String {
79 steps.iter().map(|s| format!("{} min {}", s.minutes, s.title)).collect::<Vec<_>>().join("\n")
80}
81
82pub 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
105pub 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 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 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}