1use crate::card::Card;
20use crate::text::{contains_word, fold};
21use serde::Deserialize;
22use std::path::{Path, PathBuf};
23use toml_edit::{DocumentMut, Item, Table};
24
25pub const FILE: &str = "sioul-projects.toml";
27pub const OLD_FILE: &str = "sioul-cases.toml";
29pub const BEFORE_RENAME: &str = "sioul-cases.toml.before-rename";
31const TABLE: &str = "project";
33const OLD_TABLE: &str = "case";
35
36pub 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
44pub fn both_in(root: &Path) -> bool {
47 root.join(FILE).exists() && root.join(OLD_FILE).exists()
48}
49
50pub 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 #[serde(rename = "case", default)]
61 older: Vec<Project>,
62}
63
64fn 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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
74pub struct Project {
75 pub id: String,
77 pub title: String,
78 #[serde(default)]
80 pub files: Vec<String>,
81 #[serde(default)]
83 pub status: Option<String>,
84 #[serde(rename = "route", default)]
86 pub routes: Vec<Route>,
87 #[serde(default)]
90 pub kind: Option<String>,
91 #[serde(default)]
93 pub client: Option<String>,
94 #[serde(default)]
96 pub rate: Option<f64>,
97 #[serde(default)]
99 pub budget: Option<String>,
100 #[serde(default)]
102 pub area: Option<String>,
103 #[serde(default)]
108 pub ai: bool,
109}
110
111impl Project {
112 pub fn is_for_client(&self) -> bool {
114 self.kind.as_deref() == Some("project")
115 }
116}
117
118#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, Deserialize)]
120pub struct ProjectEdit {
121 pub title: String,
122 #[serde(default)]
124 pub kind: String,
125 #[serde(default)]
126 pub status: String,
127 #[serde(default)]
128 pub client: String,
129 #[serde(default)]
131 pub rate: f64,
132 #[serde(default)]
133 pub budget: String,
134 #[serde(default)]
136 pub area: String,
137 #[serde(default)]
139 pub ai: Option<bool>,
140}
141
142impl ProjectEdit {
143 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
158pub 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
181fn 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
195fn 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
203fn 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
220fn 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
225fn 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
231pub fn save_project(file: &Path, id: &str, edit: &ProjectEdit) -> Result<String, String> {
236 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
272fn 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
282pub fn set_ai(file: &Path, project_id: &str, open: bool) -> Result<(), String> {
286 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
295pub 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 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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
355pub struct Route {
356 #[serde(default)]
358 pub from_domains: Vec<String>,
359 #[serde(default)]
360 pub from_addresses: Vec<String>,
361 #[serde(default)]
363 pub subject_contains: Vec<String>,
364 #[serde(default)]
365 pub text_contains: Vec<String>,
366 #[serde(default)]
368 pub attachment_contains: Vec<String>,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum RouteField {
374 SenderDomain,
375 Sender,
376 Subject,
377 Text,
378 Attachment,
379 Thread,
381}
382
383fn 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#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct RouteMatch {
393 pub field: RouteField,
394 pub value: String,
395}
396
397impl Route {
398 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 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 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
465fn 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#[derive(Debug, Clone)]
475pub struct ProjectStore {
476 pub root: PathBuf,
477 pub projects: Vec<Project>,
478 pub ties: std::collections::BTreeMap<String, String>,
481}
482
483#[derive(Debug, Clone)]
485pub struct Routing<'a> {
486 pub project: &'a Project,
487 pub matched: Vec<RouteMatch>,
488}
489
490impl ProjectStore {
491 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 pub fn file(&self) -> PathBuf {
502 file_in(&self.root)
503 }
504
505 pub fn with_ties(mut self, links: &crate::links::LocalLinks) -> ProjectStore {
507 self.ties = crate::links::mail_projects(links);
508 self
509 }
510
511 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 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 pub fn get(&self, id: &str) -> Option<&Project> {
546 self.projects.iter().find(|p| fold(&p.id) == fold(id))
547 }
548}
549
550#[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
578pub fn remove_project(file: &Path, project_id: &str) -> Result<(), String> {
581 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
599pub fn set_routes(file: &Path, project_id: &str, routes: &[RouteValue]) -> Result<(), String> {
602 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 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 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 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 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 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 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 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 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 assert_eq!(ProjectStore::load(&dir).unwrap().projects, before);
740 assert!(ProjectStore::load(&dir).unwrap().get("lumen").unwrap().ai);
741 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 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 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 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 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(¬ice).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("e);
836 assert_eq!(routed.first().map(|r| (r.project.id.as_str(), r.matched[0].field)), Some(("lumen", RouteField::Attachment)));
837 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}