Skip to main content

sioul_core/
projects.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Projects: every matter you follow, in one list.
5//!
6//! A project is work for a client, its time billable, or a matter of your
7//! own: a tax return, a health-cover request, a bill to contest. Its detailed
8//! record lives outside Sioul, in your notes folder: Markdown files you own
9//! (an Obsidian vault, a git repository, a folder of notes). Sioul reads one
10//! file at its root, `sioul-projects.toml`, which names each project, points
11//! at its files, and says which mail belongs to it (docs/notes-folder.md).
12//! Sioul links to the record; it never owns it.
13//!
14//! The file's first name was `sioul-cases.toml`, its tables `[[case]]`. Both
15//! names are still read, and writing keeps the names a file already has, so
16//! that a device not yet updated reads it as before. Renaming it is the
17//! person's choice, made once every device is updated (`rename_file`).
18
19use crate::card::Card;
20use crate::text::{contains_word, fold};
21use serde::Deserialize;
22use std::path::{Path, PathBuf};
23use toml_edit::{DocumentMut, Item, Table};
24
25/// The projects' file, at the root of the notes folder.
26pub const FILE: &str = "sioul-projects.toml";
27/// The file's first name: still read when only it is there (`file_in`).
28pub const OLD_FILE: &str = "sioul-cases.toml";
29/// The copy of the old file that renaming keeps beside the new one (`rename_file`).
30pub const BEFORE_RENAME: &str = "sioul-cases.toml.before-rename";
31/// The table each project is written in: `[[project]]`.
32const TABLE: &str = "project";
33/// The table's first name, `[[case]]`: a file written before keeps it.
34const OLD_TABLE: &str = "case";
35
36/// The projects' file of a notes folder: `sioul-projects.toml`, or
37/// `sioul-cases.toml` when only that one is there. With neither, the new
38/// name: the first project made makes it.
39pub fn file_in(root: &Path) -> PathBuf {
40    let (new, old) = (root.join(FILE), root.join(OLD_FILE));
41    if !new.exists() && old.exists() { old } else { new }
42}
43
44/// Both files are in the notes folder: `sioul-projects.toml` is read, and
45/// `sioul-cases.toml` is left as it is (Settings says so).
46pub fn both_in(root: &Path) -> bool {
47    root.join(FILE).exists() && root.join(OLD_FILE).exists()
48}
49
50/// Only the file's first name is there: Settings offers to rename it.
51pub fn only_old_in(root: &Path) -> bool {
52    !root.join(FILE).exists() && root.join(OLD_FILE).exists()
53}
54
55#[derive(Debug, Deserialize)]
56struct Manifest {
57    #[serde(rename = "project", default)]
58    projects: Vec<Project>,
59    /// The table's first name, `[[case]]`.
60    #[serde(rename = "case", default)]
61    older: Vec<Project>,
62}
63
64/// The projects a file's text names, under either table's name.
65fn read_projects(text: &str) -> Result<Vec<Project>, toml::de::Error> {
66    let manifest: Manifest = toml::from_str(text)?;
67    let mut projects = manifest.projects;
68    projects.extend(manifest.older);
69    Ok(projects)
70}
71
72/// One project, as its file describes it.
73#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
74pub struct Project {
75    /// Short and stable: tasks, events and notes refer to it (RFC 9253 REFID).
76    pub id: String,
77    pub title: String,
78    /// Files of the record, relative to the notes folder's root.
79    #[serde(default)]
80    pub files: Vec<String>,
81    /// "open", "waiting" or "closed"; free text is kept as written.
82    #[serde(default)]
83    pub status: Option<String>,
84    /// Which mail belongs here: a message matches if any route matches.
85    #[serde(rename = "route", default)]
86    pub routes: Vec<Route>,
87    /// "project" for work done for a client, its time billable; unsaid for a
88    /// matter of your own (housing, health, a trip).
89    #[serde(default)]
90    pub kind: Option<String>,
91    /// Who it is for: a name, or a contact's address ("sioul:contact/…").
92    #[serde(default)]
93    pub client: Option<String>,
94    /// An hour's fee; the invoice settings' when unsaid.
95    #[serde(default)]
96    pub rate: Option<f64>,
97    /// The budget its invoices are expected in.
98    #[serde(default)]
99    pub budget: Option<String>,
100    /// "personal" for a matter of yours outside work (a garden, a trip): it stays in view in quiet time.
101    #[serde(default)]
102    pub area: Option<String>,
103    /// Open to AI agents (`ai = true`): an agent connected with `sioul mcp`
104    /// reads its things and writes into it (`consent`, docs/ai.md). Every
105    /// project is closed until you open it: a file written before the field
106    /// existed opens none.
107    #[serde(default)]
108    pub ai: bool,
109}
110
111impl Project {
112    /// Work done for a client: its time can be billed.
113    pub fn is_for_client(&self) -> bool {
114        self.kind.as_deref() == Some("project")
115    }
116}
117
118/// A project as its form gives it.
119#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, Deserialize)]
120pub struct ProjectEdit {
121    pub title: String,
122    /// "project" for work for a client, "" for one of yours.
123    #[serde(default)]
124    pub kind: String,
125    #[serde(default)]
126    pub status: String,
127    #[serde(default)]
128    pub client: String,
129    /// 0 when unsaid.
130    #[serde(default)]
131    pub rate: f64,
132    #[serde(default)]
133    pub budget: String,
134    /// "personal", or "" for work and admin.
135    #[serde(default)]
136    pub area: String,
137    /// Open to AI agents; unsaid (a form that does not show it), the file's value stays.
138    #[serde(default)]
139    pub ai: Option<bool>,
140}
141
142impl ProjectEdit {
143    /// A project as its form shows it.
144    pub fn of(project: &Project) -> ProjectEdit {
145        ProjectEdit {
146            title: project.title.clone(),
147            kind: project.kind.clone().unwrap_or_default(),
148            status: project.status.clone().unwrap_or_default(),
149            client: project.client.clone().unwrap_or_default(),
150            rate: project.rate.unwrap_or(0.0),
151            budget: project.budget.clone().unwrap_or_default(),
152            area: project.area.clone().unwrap_or_default(),
153            ai: Some(project.ai),
154        }
155    }
156}
157
158/// A short, stable id from a title: "Studio Lumen — site" → "studio-lumen-site";
159/// another number when it is taken.
160pub fn new_id(title: &str, taken: &[String]) -> String {
161    let folded: String = crate::text::fold(title).into_iter().collect();
162    let mut id = String::new();
163    for c in folded.chars() {
164        if c.is_ascii_alphanumeric() {
165            id.push(c);
166        } else if !id.ends_with('-') && !id.is_empty() {
167            id.push('-');
168        }
169    }
170    let id = id.trim_end_matches('-').chars().take(40).collect::<String>();
171    let id = if id.is_empty() { "project".to_string() } else { id };
172    let mut candidate = id.clone();
173    let mut n = 2;
174    while taken.iter().any(|t| t == &candidate) {
175        candidate = format!("{id}-{n}");
176        n += 1;
177    }
178    candidate
179}
180
181/// The file's text as toml_edit holds it, comments and all. A file not there
182/// yet reads as empty when `missing_empty`; one there but unreadable (its
183/// rights, an encoding other than UTF-8) is an error: written over, every
184/// project would go.
185fn read_doc(file: &Path, missing_empty: bool) -> Result<DocumentMut, String> {
186    let fail = |e: String| format!("{}: {e}", file.display());
187    let text = match std::fs::read_to_string(file) {
188        Ok(text) => text,
189        Err(e) if missing_empty && e.kind() == std::io::ErrorKind::NotFound => String::new(),
190        Err(e) => return Err(fail(e.to_string())),
191    };
192    text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))
193}
194
195/// Writes the file whole, through a file beside it, so that it is never half written.
196fn write_doc(file: &Path, doc: &DocumentMut) -> Result<(), String> {
197    let fail = |e: std::io::Error| format!("{}: {e}", file.display());
198    let temporary = file.with_extension("toml.new");
199    std::fs::write(&temporary, doc.to_string()).map_err(fail)?;
200    std::fs::rename(&temporary, file).map_err(fail)
201}
202
203/// The table a file's new projects go in: the one it has, `[[project]]` or
204/// `[[case]]`. A file with neither gets `[[case]]` under its first name, so
205/// that an older Sioul still reads it, and `[[project]]` otherwise.
206fn table_of(doc: &DocumentMut, file: &Path) -> &'static str {
207    if doc.contains_key(TABLE) {
208        TABLE
209    } else if doc.contains_key(OLD_TABLE) || file.file_name().is_some_and(|name| name == OLD_FILE) {
210        OLD_TABLE
211    } else {
212        TABLE
213    }
214}
215
216fn id_of(table: &Table) -> Option<&str> {
217    table.get("id").and_then(Item::as_str)
218}
219
220/// Every project's id in the file, under either table's name.
221fn ids_in(doc: &DocumentMut) -> Vec<String> {
222    [TABLE, OLD_TABLE].iter().filter_map(|name| doc.get(name).and_then(Item::as_array_of_tables)).flat_map(|list| list.iter()).filter_map(|t| id_of(t).map(str::to_string)).collect()
223}
224
225/// A project's entry in the file, whichever table holds it.
226fn entry_mut<'d>(doc: &'d mut DocumentMut, id: &str) -> Option<&'d mut Table> {
227    let name = [TABLE, OLD_TABLE].into_iter().find(|name| doc.get(name).and_then(Item::as_array_of_tables).is_some_and(|list| list.iter().any(|t| id_of(t) == Some(id))))?;
228    doc.get_mut(name)?.as_array_of_tables_mut()?.iter_mut().find(|t| id_of(t) == Some(id))
229}
230
231/// Writes a project's fields from its form, in place, its comments and routes
232/// kept; a new project (`id` not there yet) goes at the end, and an empty
233/// `id` is made from its title. Returns its id. `file` is the notes folder's
234/// projects' file (`file_in`): a file not there yet is made.
235pub fn save_project(file: &Path, id: &str, edit: &ProjectEdit) -> Result<String, String> {
236    // Read, changed and written under the file's lock, which the sharing takes too.
237    crate::filelock::with_lock(file, || {
238        use toml_edit::{ArrayOfTables, value};
239        let fail = |e: String| format!("{}: {e}", file.display());
240        let mut doc = read_doc(file, true)?;
241        let taken = ids_in(&doc);
242        let id = if id.is_empty() { new_id(&edit.title, &taken) } else { id.to_string() };
243        if !taken.contains(&id) {
244            let name = table_of(&doc, file);
245            let list = doc.entry(name).or_insert(Item::ArrayOfTables(ArrayOfTables::new())).as_array_of_tables_mut().ok_or_else(|| fail(format!("`{name}` is not a list of tables")))?;
246            let mut table = Table::new();
247            table["id"] = value(id.as_str());
248            list.push(table);
249        }
250        let project = entry_mut(&mut doc, &id).ok_or_else(|| fail(format!("no project {id}")))?;
251        project["title"] = value(edit.title.trim());
252        for (key, text) in [("kind", edit.kind.trim()), ("status", edit.status.trim()), ("client", edit.client.trim()), ("budget", edit.budget.trim()), ("area", edit.area.trim())] {
253            if text.is_empty() {
254                project.remove(key);
255            } else {
256                project[key] = value(text);
257            }
258        }
259        if edit.rate > 0.0 {
260            project["rate"] = value(edit.rate);
261        } else {
262            project.remove("rate");
263        }
264        if let Some(open) = edit.ai {
265            write_ai(project, open);
266        }
267        write_doc(file, &doc)?;
268        Ok(id)
269    })
270}
271
272/// A project open to AI agents says so (`ai = true`); a closed one says
273/// nothing, as every project is closed until opened.
274fn write_ai(project: &mut Table, open: bool) {
275    if open {
276        project["ai"] = toml_edit::value(true);
277    } else {
278        project.remove("ai");
279    }
280}
281
282/// Opens a project to AI agents, or closes it, in its file, in place: its
283/// other fields and the file's comments stay. The sharing carries the
284/// project's entry, so the choice holds on every device.
285pub fn set_ai(file: &Path, project_id: &str, open: bool) -> Result<(), String> {
286    // Read, changed and written under the file's lock, which the sharing takes too.
287    crate::filelock::with_lock(file, || {
288        let mut doc = read_doc(file, false)?;
289        let project = entry_mut(&mut doc, project_id).ok_or_else(|| format!("{}: no project {project_id}", file.display()))?;
290        write_ai(project, open);
291        write_doc(file, &doc)
292    })
293}
294
295/// Renames the file's first name, `sioul-cases.toml`, to
296/// `sioul-projects.toml`, and its `[[case]]` tables to `[[project]]`, in one
297/// write: every field, route and comment kept, and checked to be so before
298/// anything is written. The old file stays beside the new one, as
299/// `sioul-cases.toml.before-rename`. Never done by itself: an older Sioul
300/// reads only the first name, so the person chooses when (Settings ▸ Your
301/// folder and sharing). Refused when `sioul-projects.toml` is already there.
302/// Returns where the old file was kept.
303pub fn rename_file(root: &Path) -> Result<PathBuf, String> {
304    let (old, new) = (root.join(OLD_FILE), root.join(FILE));
305    if new.exists() {
306        return Err(format!("{}: already there; {OLD_FILE} is left as it is.", new.display()));
307    }
308    let text = std::fs::read_to_string(&old).map_err(|e| format!("{}: {e}", old.display()))?;
309    let fail = |e: String| format!("{}: {e}", old.display());
310    let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
311    if let Some(older) = doc.remove(OLD_TABLE) {
312        let Item::ArrayOfTables(older) = older else { return Err(fail(format!("`{OLD_TABLE}` is not a list of tables"))) };
313        match doc.get_mut(TABLE) {
314            None => {
315                doc.insert(TABLE, Item::ArrayOfTables(older));
316            }
317            Some(Item::ArrayOfTables(list)) => {
318                for table in older.iter() {
319                    list.push(table.clone());
320                }
321            }
322            Some(_) => return Err(fail(format!("`{TABLE}` is not a list of tables"))),
323        }
324    }
325    let renamed = doc.to_string();
326    // Nothing lost: the same projects in the same order, every field the
327    // same, and the rest of the file as it was.
328    let before: toml::Table = text.parse().map_err(|e: toml::de::Error| fail(e.to_string()))?;
329    let after: toml::Table = renamed.parse().map_err(|e: toml::de::Error| fail(e.to_string()))?;
330    let split = |mut table: toml::Table| {
331        let mut list = match table.remove(TABLE) {
332            Some(toml::Value::Array(list)) => list,
333            _ => Vec::new(),
334        };
335        if let Some(toml::Value::Array(older)) = table.remove(OLD_TABLE) {
336            list.extend(older);
337        }
338        (list, table)
339    };
340    if split(before) != split(after) {
341        return Err(fail("renaming it would change what it says: it is left as it is".into()));
342    }
343    std::fs::write(new.with_extension("toml.new"), &renamed).map_err(|e| fail(e.to_string()))?;
344    std::fs::rename(new.with_extension("toml.new"), &new).map_err(|e| format!("{}: {e}", new.display()))?;
345    let kept = (1..)
346        .map(|n| if n == 1 { root.join(BEFORE_RENAME) } else { root.join(format!("{BEFORE_RENAME}-{n}")) })
347        .find(|path| !path.exists())
348        .unwrap_or_else(|| root.join(BEFORE_RENAME));
349    std::fs::rename(&old, &kept).map_err(|e| fail(e.to_string()))?;
350    Ok(kept)
351}
352
353/// A route matches when every list it fills has at least one match.
354#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
355pub struct Route {
356    /// The sender's domain or any of its subdomains: "finances.example" covers "dgfip.finances.example".
357    #[serde(default)]
358    pub from_domains: Vec<String>,
359    #[serde(default)]
360    pub from_addresses: Vec<String>,
361    /// Whole words or phrases, case and accents ignored.
362    #[serde(default)]
363    pub subject_contains: Vec<String>,
364    #[serde(default)]
365    pub text_contains: Vec<String>,
366    /// Words in an attachment's file name: "devis", "facture", ".ics".
367    #[serde(default)]
368    pub attachment_contains: Vec<String>,
369}
370
371/// Which field of a message matched a route.
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum RouteField {
374    SenderDomain,
375    Sender,
376    Subject,
377    Text,
378    Attachment,
379    /// Not a route: a message of the same conversation belongs to the project.
380    Thread,
381}
382
383/// Whether a file name holds a word, case and accents aside: names are not prose.
384fn name_contains(name: &str, word: &str) -> bool {
385    let folded = |s: &str| fold(s.trim()).into_iter().flat_map(char::to_lowercase).collect::<String>();
386    let word = folded(word);
387    !word.is_empty() && folded(name).contains(&word)
388}
389
390/// One condition of a route that a message met: the field and the value it matched.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct RouteMatch {
393    pub field: RouteField,
394    pub value: String,
395}
396
397impl Route {
398    /// What in the card this route matched; none if it does not take the card.
399    pub fn explain(&self, card: &Card) -> Option<Vec<RouteMatch>> {
400        if self.is_empty() {
401            return None;
402        }
403        let domain = card.sender_domain().unwrap_or("");
404        let address = card.from_address.as_deref().unwrap_or("");
405        let checks = [
406            any_match(&self.from_domains, |d| domain_matches(domain, d), RouteField::SenderDomain),
407            any_match(&self.from_addresses, |a| address.eq_ignore_ascii_case(a), RouteField::Sender),
408            any_match(&self.subject_contains, |w| contains_word(&card.subject, w), RouteField::Subject),
409            any_match(&self.text_contains, |w| contains_word(&card.excerpt, w), RouteField::Text),
410            any_match(&self.attachment_contains, |w| card.attachments.iter().any(|name| name_contains(name, w)), RouteField::Attachment),
411        ];
412        let mut matched = Vec::new();
413        for check in checks {
414            match check {
415                Check::Unused => {}
416                Check::Matched(m) => matched.push(m),
417                Check::Failed => return None,
418            }
419        }
420        Some(matched)
421    }
422
423    /// Whether a message with this sender and subject is taken, as far as
424    /// they tell: a route that needs words in the text or an attachment never
425    /// matches here (such mail is tied to its project when it arrives).
426    pub fn takes_header(&self, address: &str, subject: &str) -> bool {
427        if self.is_empty() || !self.text_contains.is_empty() || !self.attachment_contains.is_empty() {
428            return false;
429        }
430        let domain = address.rsplit_once('@').map_or("", |(_, d)| d);
431        (self.from_domains.is_empty() || self.from_domains.iter().any(|d| domain_matches(domain, d)))
432            && (self.from_addresses.is_empty() || self.from_addresses.iter().any(|a| address.eq_ignore_ascii_case(a)))
433            && (self.subject_contains.is_empty() || self.subject_contains.iter().any(|w| contains_word(subject, w)))
434    }
435
436    fn is_empty(&self) -> bool {
437        self.from_domains.is_empty()
438            && self.from_addresses.is_empty()
439            && self.subject_contains.is_empty()
440            && self.text_contains.is_empty()
441            && self.attachment_contains.is_empty()
442    }
443
444    /// Whether only the whole message tells: words in its text or an attachment.
445    pub fn needs_body(&self) -> bool {
446        !self.text_contains.is_empty() || !self.attachment_contains.is_empty()
447    }
448}
449
450enum Check {
451    Unused,
452    Matched(RouteMatch),
453    Failed,
454}
455
456fn any_match(list: &[String], test: impl Fn(&str) -> bool, field: RouteField) -> Check {
457    if list.is_empty() {
458        return Check::Unused;
459    }
460    list.iter()
461        .find(|item| test(item))
462        .map_or(Check::Failed, |item| Check::Matched(RouteMatch { field, value: item.clone() }))
463}
464
465/// "dgfip.finances.example" matches "finances.example"; "notfinances.example" does not.
466fn domain_matches(domain: &str, pattern: &str) -> bool {
467    let domain = domain.to_ascii_lowercase();
468    let pattern = pattern.trim_start_matches('@').to_ascii_lowercase();
469    domain == pattern || domain.ends_with(&format!(".{pattern}"))
470}
471
472
473/// The projects of one notes folder.
474#[derive(Debug, Clone)]
475pub struct ProjectStore {
476    pub root: PathBuf,
477    pub projects: Vec<Project>,
478    /// Messages tied to a project, by Message-ID (bare): by hand, or when they
479    /// arrived. A reply in their conversation goes to the same project.
480    pub ties: std::collections::BTreeMap<String, String>,
481}
482
483/// A project a message was routed to, and what matched.
484#[derive(Debug, Clone)]
485pub struct Routing<'a> {
486    pub project: &'a Project,
487    pub matched: Vec<RouteMatch>,
488}
489
490impl ProjectStore {
491    /// Reads the projects' file at the root of a notes folder
492    /// (`file_in`: `sioul-projects.toml`, or `sioul-cases.toml`).
493    pub fn load(root: &Path) -> Result<ProjectStore, String> {
494        let path = file_in(root);
495        let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
496        let projects = read_projects(&text).map_err(|e| format!("{}: {e}", path.display()))?;
497        Ok(ProjectStore { root: root.to_path_buf(), projects, ties: Default::default() })
498    }
499
500    /// The file its projects are written to (`file_in`).
501    pub fn file(&self) -> PathBuf {
502        file_in(&self.root)
503    }
504
505    /// With the messages tied to projects, from the links Sioul keeps.
506    pub fn with_ties(mut self, links: &crate::links::LocalLinks) -> ProjectStore {
507        self.ties = crate::links::mail_projects(links);
508        self
509    }
510
511    /// Every project a message belongs to, in the file's order: the first is
512    /// its home. Without a route, a message tied to a project, or answering
513    /// or citing one that is, belongs to it.
514    pub fn route(&self, card: &Card) -> Vec<Routing<'_>> {
515        let routed: Vec<Routing<'_>> = self
516            .projects
517            .iter()
518            .filter_map(|project| {
519                let matched = project.routes.iter().find_map(|r| r.explain(card))?;
520                Some(Routing { project, matched })
521            })
522            .collect();
523        if !routed.is_empty() || self.ties.is_empty() {
524            return routed;
525        }
526        crate::threads::ids_of(card)
527            .iter()
528            .find_map(|id| self.ties.get(id))
529            .and_then(|id| self.get(id))
530            .map(|project| Routing { project, matched: vec![RouteMatch { field: RouteField::Thread, value: String::new() }] })
531            .into_iter()
532            .collect()
533    }
534
535    /// Files the projects' file names that are not there (moved or renamed).
536    pub fn missing_files(&self) -> Vec<(String, String)> {
537        self.projects
538            .iter()
539            .flat_map(|p| p.files.iter().map(move |f| (p.id.clone(), f.clone())))
540            .filter(|(_, f)| !self.root.join(f).exists())
541            .collect()
542    }
543
544    /// A project by id.
545    pub fn get(&self, id: &str) -> Option<&Project> {
546        self.projects.iter().find(|p| fold(&p.id) == fold(id))
547    }
548}
549
550/// A route as the settings edit it: each list may be empty.
551#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, Deserialize)]
552pub struct RouteValue {
553    #[serde(default)]
554    pub from_domains: Vec<String>,
555    #[serde(default)]
556    pub from_addresses: Vec<String>,
557    #[serde(default)]
558    pub subject_contains: Vec<String>,
559    #[serde(default)]
560    pub text_contains: Vec<String>,
561    #[serde(default)]
562    pub attachment_contains: Vec<String>,
563}
564
565impl From<&Route> for RouteValue {
566    fn from(route: &Route) -> RouteValue {
567        RouteValue {
568            from_domains: route.from_domains.clone(),
569            from_addresses: route.from_addresses.clone(),
570            subject_contains: route.subject_contains.clone(),
571            text_contains: route.text_contains.clone(),
572            attachment_contains: route.attachment_contains.clone(),
573        }
574    }
575}
576
577
578/// Takes a project out of its file, the file's comments elsewhere kept. Its
579/// tasks, notes, mail and time stay; they are no longer gathered under it.
580pub fn remove_project(file: &Path, project_id: &str) -> Result<(), String> {
581    // Read, changed and written under the file's lock, which the sharing takes too.
582    crate::filelock::with_lock(file, || {
583        let mut doc = read_doc(file, false)?;
584        let mut found = false;
585        for name in [TABLE, OLD_TABLE] {
586            if let Some(list) = doc.get_mut(name).and_then(Item::as_array_of_tables_mut) {
587                let before = list.len();
588                list.retain(|t| id_of(t) != Some(project_id));
589                found |= list.len() != before;
590            }
591        }
592        if !found {
593            return Err(format!("{}: no project {project_id}", file.display()));
594        }
595        write_doc(file, &doc)
596    })
597}
598
599/// Replaces a project's routes in its file, in place: its other fields and
600/// the file's comments stay. Routes left empty are dropped.
601pub fn set_routes(file: &Path, project_id: &str, routes: &[RouteValue]) -> Result<(), String> {
602    // Read, changed and written under the file's lock, which the sharing takes too.
603    crate::filelock::with_lock(file, || {
604        use toml_edit::{Array, ArrayOfTables, value};
605        let mut doc = read_doc(file, false)?;
606        let project = entry_mut(&mut doc, project_id).ok_or_else(|| format!("{}: no project {project_id}", file.display()))?;
607        let mut tables = ArrayOfTables::new();
608        for route in routes {
609            let mut table = Table::new();
610            for (key, list) in [
611                ("from_domains", &route.from_domains),
612                ("from_addresses", &route.from_addresses),
613                ("subject_contains", &route.subject_contains),
614                ("text_contains", &route.text_contains),
615                ("attachment_contains", &route.attachment_contains),
616            ] {
617                let cleaned: Vec<&str> = list.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
618                if !cleaned.is_empty() {
619                    table[key] = value(cleaned.into_iter().collect::<Array>());
620                }
621            }
622            if !table.is_empty() {
623                tables.push(table);
624            }
625        }
626        if tables.is_empty() {
627            project.remove("route");
628        } else {
629            project["route"] = Item::ArrayOfTables(tables);
630        }
631        write_doc(file, &doc)
632    })
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    /// An empty folder of its own for a test, under the system's temporary folder.
640    fn scratch(name: &str) -> PathBuf {
641        let dir = std::env::temp_dir().join(format!("sioul-{name}-{}", std::process::id()));
642        let _ = std::fs::remove_dir_all(&dir);
643        std::fs::create_dir_all(&dir).unwrap();
644        dir
645    }
646
647    #[test]
648    fn projects_are_written_in_place() {
649        let dir = scratch("projects");
650        let file = dir.join(FILE);
651        std::fs::write(&file, "# My projects\n[[project]]\nid = \"housing\"\ntitle = \"Housing\"\n\n[[project.route]]\nfrom_domains = [\"caf.example\"]\n").unwrap();
652        let edit = ProjectEdit { title: "Studio Lumen — site".into(), kind: "project".into(), client: "Studio Lumen".into(), rate: 60.0, ..ProjectEdit::default() };
653        let id = save_project(&file, "", &edit).unwrap();
654        assert_eq!(id, "studio-lumen-site");
655        let store = ProjectStore::load(&dir).unwrap();
656        let lumen = store.get(&id).unwrap();
657        assert!(lumen.is_for_client());
658        assert_eq!((lumen.client.as_deref(), lumen.rate), (Some("Studio Lumen"), Some(60.0)));
659        // Edited in place: the routes and the comment stay.
660        save_project(&file, "housing", &ProjectEdit { title: "Housing".into(), status: "waiting".into(), ..ProjectEdit::default() }).unwrap();
661        let text = std::fs::read_to_string(&file).unwrap();
662        assert!(text.starts_with("# My projects"));
663        assert!(text.contains("from_domains = [\"caf.example\"]"));
664        assert_eq!(ProjectStore::load(&dir).unwrap().get("housing").unwrap().status.as_deref(), Some("waiting"));
665        assert_eq!(new_id("Studio Lumen — site", &[id]), "studio-lumen-site-2");
666        // A file that cannot be read (here, not UTF-8) is never written over.
667        let broken = b"# Mes projets\n[[project]]\nid = \"caf\xe9\"\n".to_vec();
668        std::fs::write(&file, &broken).unwrap();
669        assert!(save_project(&file, "", &ProjectEdit { title: "New".into(), ..ProjectEdit::default() }).is_err());
670        assert_eq!(std::fs::read(&file).unwrap(), broken);
671        let _ = std::fs::remove_dir_all(&dir);
672    }
673
674    #[test]
675    fn an_old_file_reads_and_is_written_as_before() {
676        let dir = scratch("old-file");
677        let old = dir.join(OLD_FILE);
678        std::fs::write(&old, "# My cases\n[[case]]\nid = \"taxes\"\ntitle = \"Taxes\"\n[[case.route]]\nfrom_domains = [\"finances.example\"]\n").unwrap();
679        assert_eq!(file_in(&dir), old);
680        assert!(only_old_in(&dir) && !both_in(&dir));
681        let store = ProjectStore::load(&dir).unwrap();
682        assert_eq!((store.projects.len(), store.projects[0].routes[0].from_domains[0].as_str()), (1, "finances.example"));
683        // Everything written keeps its names: an older Sioul still reads it.
684        save_project(&store.file(), "", &ProjectEdit { title: "Lease".into(), ..ProjectEdit::default() }).unwrap();
685        set_ai(&store.file(), "taxes", true).unwrap();
686        set_routes(&store.file(), "lease", &[RouteValue { from_domains: vec!["landlord.example".into()], ..RouteValue::default() }]).unwrap();
687        let text = std::fs::read_to_string(&old).unwrap();
688        assert!(!dir.join(FILE).exists());
689        assert!(text.starts_with("# My cases") && !text.contains("[[project") && text.matches("[[case]]").count() == 2 && text.contains("[[case.route]]"), "{text}");
690        let store = ProjectStore::load(&dir).unwrap();
691        assert_eq!(store.projects.iter().map(|p| (p.id.as_str(), p.ai, p.routes.len())).collect::<Vec<_>>(), [("taxes", true, 1), ("lease", false, 1)]);
692        remove_project(&old, "lease").unwrap();
693        assert!(ProjectStore::load(&dir).unwrap().get("lease").is_none());
694        assert!(remove_project(&old, "lease").is_err());
695        // An old file with no project yet: its first one is written as an older Sioul reads it.
696        std::fs::write(&old, "# Nothing yet.\n").unwrap();
697        save_project(&old, "", &ProjectEdit { title: "Garden".into(), ..ProjectEdit::default() }).unwrap();
698        assert!(std::fs::read_to_string(&old).unwrap().contains("[[case]]"));
699        std::fs::remove_dir_all(&dir).unwrap();
700    }
701
702    #[test]
703    fn a_new_folder_gets_the_new_names() {
704        let dir = scratch("new-file");
705        assert_eq!(file_in(&dir), dir.join(FILE));
706        assert!(ProjectStore::load(&dir).is_err());
707        save_project(&file_in(&dir), "", &ProjectEdit { title: "Garden".into(), ..ProjectEdit::default() }).unwrap();
708        let text = std::fs::read_to_string(dir.join(FILE)).unwrap();
709        assert!(text.contains("[[project]]") && !text.contains("case"), "{text}");
710        assert_eq!(ProjectStore::load(&dir).unwrap().projects[0].id, "garden");
711        // Both files there: the new one is read, and the old one left as it is.
712        std::fs::write(dir.join(OLD_FILE), "[[case]]\nid = \"old\"\ntitle = \"Old\"\n").unwrap();
713        assert!(both_in(&dir) && !only_old_in(&dir));
714        assert_eq!(ProjectStore::load(&dir).unwrap().projects.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), ["garden"]);
715        // A file holding both tables, edited by hand: every project is read and found.
716        std::fs::write(dir.join(FILE), "[[project]]\nid = \"a\"\ntitle = \"A\"\n\n[[case]]\nid = \"b\"\ntitle = \"B\"\n").unwrap();
717        assert_eq!(ProjectStore::load(&dir).unwrap().projects.len(), 2);
718        set_ai(&dir.join(FILE), "b", true).unwrap();
719        assert!(ProjectStore::load(&dir).unwrap().get("b").unwrap().ai);
720        std::fs::remove_dir_all(&dir).unwrap();
721    }
722
723    #[test]
724    fn renaming_keeps_everything_and_a_copy() {
725        let dir = scratch("rename");
726        let old = dir.join(OLD_FILE);
727        let text = "# My cases: kept as I wrote them.\n\n[[case]] # the taxes\nid = \"taxes\"\ntitle = \"Taxes\"\nfiles = [\"admin/taxes.md\"]\nstatus = \"open\"\n\n[[case.route]]\nfrom_domains = [\"finances.example\"]\nsubject_contains = [\"avis\"]\n\n[[case.route]]\nattachment_contains = [\"devis\"]\n\n# A client.\n[[case]]\nid = \"lumen\"\ntitle = \"Studio Lumen\"\nkind = \"project\"\nclient = \"sioul:contact/lumen\"\nrate = 60.5\nbudget = \"work\"\narea = \"personal\"\nai = true\n";
728        std::fs::write(&old, text).unwrap();
729        let before = ProjectStore::load(&dir).unwrap().projects;
730        assert_eq!(before.len(), 2);
731        let kept = rename_file(&dir).unwrap();
732        // The copy, beside it, byte for byte; the old name gone; the new one read.
733        assert_eq!((kept.clone(), std::fs::read_to_string(&kept).unwrap()), (dir.join(BEFORE_RENAME), text.to_string()));
734        assert!(!old.exists() && file_in(&dir) == dir.join(FILE));
735        let renamed = std::fs::read_to_string(dir.join(FILE)).unwrap();
736        assert!(renamed.starts_with("# My cases: kept as I wrote them.") && renamed.contains("[[project]] # the taxes") && renamed.contains("# A client.\n[[project]]"), "{renamed}");
737        assert!(renamed.matches("[[project.route]]").count() == 2 && !renamed.contains("[[case"), "{renamed}");
738        // Every field, the AI field included, in the same order.
739        assert_eq!(ProjectStore::load(&dir).unwrap().projects, before);
740        assert!(ProjectStore::load(&dir).unwrap().get("lumen").unwrap().ai);
741        // Never twice, and never over a file of the new name.
742        assert!(rename_file(&dir).is_err());
743        std::fs::write(&old, text).unwrap();
744        assert!(rename_file(&dir).is_err());
745        assert_eq!(std::fs::read_to_string(&old).unwrap(), text);
746        // A second renaming, later, keeps the first copy.
747        std::fs::remove_file(dir.join(FILE)).unwrap();
748        assert_eq!(rename_file(&dir).unwrap(), dir.join(format!("{BEFORE_RENAME}-2")));
749        assert_eq!(std::fs::read_to_string(dir.join(BEFORE_RENAME)).unwrap(), text);
750        std::fs::remove_dir_all(&dir).unwrap();
751    }
752
753    #[test]
754    fn routes_are_written_in_place() {
755        let dir = scratch("routes");
756        let path = dir.join(FILE);
757        std::fs::write(&path, "# My projects.\n[[project]]\nid = \"taxes\"\ntitle = \"Taxes\"\n[[project.route]]\nfrom_domains = [\"old.example\"]\n\n[[project]]\nid = \"bill\"\ntitle = \"Bill\"\n").unwrap();
758        set_routes(&path, "taxes", &[RouteValue { from_domains: vec!["finances.example".into()], subject_contains: vec!["avis".into()], ..RouteValue::default() }, RouteValue::default()]).unwrap();
759        let store = ProjectStore::load(&dir).unwrap();
760        assert_eq!(store.projects[0].routes.len(), 1);
761        assert_eq!((store.projects[0].routes[0].from_domains.clone(), store.projects[0].routes[0].subject_contains.clone()), (vec!["finances.example".to_string()], vec!["avis".to_string()]));
762        assert_eq!(store.projects[1].title, "Bill");
763        assert!(std::fs::read_to_string(&path).unwrap().starts_with("# My projects."));
764        std::fs::remove_dir_all(&dir).unwrap();
765    }
766
767    #[test]
768    fn projects_are_closed_to_agents_until_opened() {
769        let dir = scratch("ai");
770        let path = dir.join(FILE);
771        // A file written before the field: every project closed.
772        std::fs::write(&path, "# Mine.\n[[project]]\nid = \"taxes\"\ntitle = \"Taxes\"\n\n[[project]]\nid = \"lumen\"\ntitle = \"Studio Lumen\"\nkind = \"project\"\n").unwrap();
773        assert!(ProjectStore::load(&dir).unwrap().projects.iter().all(|p| !p.ai));
774        set_ai(&path, "lumen", true).unwrap();
775        let store = ProjectStore::load(&dir).unwrap();
776        assert_eq!(store.projects.iter().map(|p| (p.id.as_str(), p.ai)).collect::<Vec<_>>(), [("taxes", false), ("lumen", true)]);
777        assert!(std::fs::read_to_string(&path).unwrap().starts_with("# Mine."));
778        // A form that does not say it keeps it; one that does changes it.
779        save_project(&path, "lumen", &ProjectEdit { title: "Studio Lumen".into(), kind: "project".into(), ..ProjectEdit::default() }).unwrap();
780        assert!(ProjectStore::load(&dir).unwrap().get("lumen").unwrap().ai);
781        assert_eq!(ProjectEdit::of(ProjectStore::load(&dir).unwrap().get("lumen").unwrap()).ai, Some(true));
782        save_project(&path, "lumen", &ProjectEdit { title: "Studio Lumen".into(), ai: Some(false), ..ProjectEdit::default() }).unwrap();
783        assert!(!ProjectStore::load(&dir).unwrap().get("lumen").unwrap().ai);
784        // Closed writes nothing: absent is closed.
785        assert!(!std::fs::read_to_string(&path).unwrap().contains("ai ="));
786        assert!(set_ai(&path, "nowhere", true).is_err());
787        std::fs::remove_dir_all(&dir).unwrap();
788    }
789
790    fn card(from: &str, subject: &str) -> Card {
791        let raw = format!("From: {from}\r\nSubject: {subject}\r\n\r\nBonjour.\r\n");
792        Card::from_bytes(raw.as_bytes()).unwrap()
793    }
794
795    fn store() -> ProjectStore {
796        let projects = read_projects(
797            r#"
798            [[project]]
799            id = "taxes"
800            title = "Taxes"
801            [[project.route]]
802            from_domains = ["finances.example"]
803
804            [[project]]
805            id = "bill"
806            title = "The canteen bill"
807            [[project.route]]
808            from_domains = ["finances.example"]
809            subject_contains = ["titre", "avis des sommes"]
810            "#,
811        )
812        .unwrap();
813        ProjectStore { root: PathBuf::from("."), projects, ties: Default::default() }
814    }
815
816    #[test]
817    fn routes_by_domain_and_subject() {
818        let s = store();
819        let notice = card("DGFiP <ne-pas-repondre@dgfip.finances.example>", "Votre avis d'impôt");
820        let ids: Vec<&str> = s.route(&notice).iter().map(|r| r.project.id.as_str()).collect();
821        assert_eq!(ids, vec!["taxes"]);
822        let bill = card("<noreply@dgfip.finances.example>", "Avis des sommes à payer");
823        let ids: Vec<&str> = s.route(&bill).iter().map(|r| r.project.id.as_str()).collect();
824        assert_eq!(ids, vec!["taxes", "bill"]);
825    }
826
827    #[test]
828    fn attachments_and_conversations() {
829        let projects = read_projects("[[case]]\nid = \"lumen\"\ntitle = \"Studio Lumen\"\n[[case.route]]\nattachment_contains = [\"devis\"]\n").unwrap();
830        let mut store = ProjectStore { root: PathBuf::from("."), projects, ties: Default::default() };
831        let quote = "From: Jane <jane@lumen.example.net>\r\nSubject: Our offer\r\nMessage-ID: <q1@lumen.example.net>\r\nMIME-Version: 1.0\r\n\
832            Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nHere it is.\r\n--b\r\n\
833            Content-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"Devis_Lumen_2026.pdf\"\r\n\r\nJVBERi0=\r\n--b--\r\n";
834        let quote = Card::from_bytes(quote.as_bytes()).unwrap();
835        let routed = store.route(&quote);
836        assert_eq!(routed.first().map(|r| (r.project.id.as_str(), r.matched[0].field)), Some(("lumen", RouteField::Attachment)));
837        // The answer has no attachment: it follows the quote once the quote is tied to the project.
838        let answer = Card::from_bytes(b"From: Jane <jane@lumen.example.net>\r\nSubject: Re: Our offer\r\nIn-Reply-To: <q1@lumen.example.net>\r\n\r\nAny question?\r\n").unwrap();
839        assert!(store.route(&answer).is_empty());
840        store.ties.insert("q1@lumen.example.net".into(), "lumen".into());
841        assert_eq!(store.route(&answer).first().map(|r| (r.project.id.as_str(), r.matched[0].field)), Some(("lumen", RouteField::Thread)));
842    }
843
844    #[test]
845    fn domain_boundaries() {
846        assert!(domain_matches("dgfip.finances.example", "finances.example"));
847        assert!(!domain_matches("notfinances.example", "finances.example"));
848        assert!(domain_matches("insurer.example", "@insurer.example"));
849    }
850}