//! Archiv: Panel-Entwürfe als Markdown-Dateien mit Frontmatter im //! konfigurierten Archiv-Ordner des Projekts persistieren. use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use crate::domain::paths::{contract_home, expand_home, panel_file, Paths}; use crate::domain::project::{ read_project_config_in, settings_path, write_project_config_in, }; use crate::domain::registry::project_dir; /// Konfiguriertes Archiv-Home des Projekts (config.json: archiveHome), /// Home-expandiert; None, wenn nicht gesetzt. pub(crate) fn project_archive_home(project: &str) -> Option { let paths = Paths::real(); let dir = read_project_config_in(&paths, project).ok()?.archive_home?; Some(expand_home(&paths, &dir)) } /// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools. /// Legt den konfigurierten Ordner an, falls er (noch) nicht existiert. pub(crate) fn require_archive_home(project: &str) -> Result { let home = project_archive_home(project).ok_or("kein Archiv-Ordner gesetzt")?; fs::create_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?; Ok(home) } /// Setzt das Archiv-Home: config.json (~-relativ) und ein Eintrag in /// permissions.additionalDirectories + Edit der Projekt-settings.json, damit /// claude das Archiv später lesen/scannen darf. pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> { let paths = Paths::real(); let expanded = expand_home(&paths, dir); if !expanded.is_absolute() { return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display())); } // Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde // claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab. if paths.home.starts_with(&expanded) { return Err(format!( "Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.", expanded.display() )); } fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?; let contracted = contract_home(&paths, &expanded); let mut cfg = read_project_config_in(&paths, project)?; cfg.archive_home = Some(contracted.clone()); write_project_config_in(&paths, project, &cfg)?; add_archive_permission(&paths, project, &contracted) } /// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein. /// Auch beim Projekt-Import im Einsatz (mitgebrachtes archiveHome). pub(crate) fn add_archive_permission( paths: &Paths, project: &str, dir: &str, ) -> Result<(), String> { let sp = settings_path(&project_dir(paths, project)?); let mut v: serde_json::Value = if sp.is_file() { serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?) .map_err(|e| format!("{}: {e}", sp.display()))? } else { serde_json::json!({}) }; let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; let perms = root .entry("permissions") .or_insert_with(|| serde_json::json!({})) .as_object_mut() .ok_or("permissions ist kein Objekt")?; let dirs = perms .entry("additionalDirectories") .or_insert_with(|| serde_json::json!([])) .as_array_mut() .ok_or("additionalDirectories ist kein Array")?; if !dirs.iter().any(|d| d.as_str() == Some(dir)) { dirs.push(serde_json::json!(dir)); } let edit = format!("Edit({dir}/**)"); let allow = perms .entry("allow") .or_insert_with(|| serde_json::json!([])) .as_array_mut() .ok_or("allow ist kein Array")?; if !allow.iter().any(|p| p.as_str() == Some(&edit)) { allow.push(serde_json::json!(edit)); } let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) } /// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die /// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus /// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen. pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> { let paths = Paths::real(); let mut cfg = read_project_config_in(&paths, project)?; let Some(dir) = cfg.archive_home.take() else { return Ok(()); }; write_project_config_in(&paths, project, &cfg)?; remove_archive_permission(&paths, project, &dir) } /// Gegenstück zu add_archive_permission. fn remove_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> { let sp = settings_path(&project_dir(paths, project)?); if !sp.is_file() { return Ok(()); } let mut v: serde_json::Value = serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?) .map_err(|e| format!("{}: {e}", sp.display()))?; let Some(perms) = v.get_mut("permissions").and_then(|p| p.as_object_mut()) else { return Ok(()); }; if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) { dirs.retain(|d| d.as_str() != Some(dir)); } let edit = format!("Edit({dir}/**)"); if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { allow.retain(|p| p.as_str() != Some(&edit)); } let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) } /// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder. #[derive(Default)] pub(crate) struct ArchiveMeta { /// Unterordner relativ zum Archiv-Home (wird angelegt). pub(crate) folder: Option, /// Einzeiler fürs Frontmatter. pub(crate) description: Option, /// Schlagwörter fürs Frontmatter. pub(crate) tags: Vec, } /// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit /// Frontmatter im Archiv-Home. `dir_override` setzt das Home zugleich /// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad. pub(crate) fn archive_panel_content( project: &str, dir_override: Option<&str>, meta: &ArchiveMeta, ) -> Result { // Erst prüfen, dann konfigurieren: `set_project_archive_home` legt Ordner an // und trägt eine Berechtigung in die settings.json des Projekts ein. Käme das // vor der Leer-Prüfung, hinterließe ein Archivieren mit leerem Panel die // Meldung „nicht archiviert“ — und ein dauerhaft umgestelltes Archiv-Home. let text = fs::read_to_string(panel_file(project)).unwrap_or_default(); if text.trim().is_empty() { return Err("Panel ist leer — nichts zu archivieren".into()); } let folder = meta.folder.as_deref().map(check_folder).transpose()?; if let Some(d) = dir_override { set_project_archive_home(project, d)?; } let home = require_archive_home(project)?; let dir = match folder { Some(f) => home.join(f), None => home, }; fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| e.to_string())? .as_secs(); let (stamp, iso) = utc_stamp(secs); let title = first_line(&text); let path = free_path(&dir, &stamp, &slugify(&title)); // Frontmatter trägt den Anzeigenamen, nicht die Projekt-ID. let name = crate::domain::project::display_name_in(&Paths::real(), project)?; let doc = format!("{}{}\n", frontmatter(&title, &name, &iso, meta), text.trim_end()); fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; Ok(path) } /// Freier Dateiname für das Archiv-Dokument. /// /// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer /// Minute mit derselben Titelzeile ergäbe denselben Namen. Da mit `fs::write` /// geschrieben wird, wäre das stiller Datenverlust — ausgerechnet im Archiv, /// das die dauerhafte Ablage ist. Darum bei Kollision `-2`, `-3`, … anhängen. fn free_path(dir: &std::path::Path, stamp: &str, slug: &str) -> PathBuf { let first = dir.join(format!("{stamp}-{slug}.md")); if !first.exists() { return first; } (2..) .map(|n| dir.join(format!("{stamp}-{slug}-{n}.md"))) .find(|p| !p.exists()) .expect("unendlicher Zahlenraum") } /// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root). fn check_folder(folder: &str) -> Result<&std::path::Path, String> { let p = std::path::Path::new(folder); let normal = p .components() .all(|c| matches!(c, std::path::Component::Normal(_))); if p.components().next().is_some() && normal { Ok(p) } else { Err(format!("Unterordner muss ein relativer Pfad ohne '..' sein: {folder}")) } } /// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler /// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten — /// Schreiber und Leser des Formats leben bewusst im selben Modul. fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String { let mut fm = format!( "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n", title.replace('"', "'"), ); if let Some(d) = &meta.description { fm.push_str(&format!("description: \"{}\"\n", d.replace('"', "'"))); } if !meta.tags.is_empty() { let quoted: Vec = meta.tags.iter().map(|t| format!("\"{}\"", t.replace('"', "'"))).collect(); fm.push_str(&format!("tags: [{}]\n", quoted.join(", "))); } fm.push_str("---\n\n"); fm } /// Minimaler Frontmatter-Parser für die selbst geschriebenen Dokumente: /// `key: value`-Zeilen zwischen den beiden `---`-Markern, Anführungszeichen /// um Werte werden entfernt. pub(crate) fn parse_frontmatter(text: &str) -> std::collections::HashMap { let mut map = std::collections::HashMap::new(); let Some(rest) = text.strip_prefix("---\n") else { return map; }; let Some(end) = rest.find("\n---") else { return map; }; for line in rest[..end].lines() { let Some((key, value)) = line.split_once(':') else { continue; }; map.insert(key.trim().to_string(), unquote(value.trim()).to_string()); } map } /// Dokument-Rumpf ohne den Frontmatter-Block; führende Leerzeilen entfernt. pub(crate) fn strip_frontmatter(text: &str) -> &str { let Some(rest) = text.strip_prefix("---\n") else { return text; }; match rest.find("\n---\n") { Some(end) => rest[end + 5..].trim_start_matches('\n'), None => text, } } fn unquote(s: &str) -> &str { s.strip_prefix('"') .and_then(|s| s.strip_suffix('"')) .unwrap_or(s) } /// Inline-Liste `["a", "b"]` bzw. `[a, b]` in Einzel-Tags zerlegen. pub(crate) fn parse_tag_list(raw: &str) -> Vec { raw .trim_start_matches('[') .trim_end_matches(']') .split(',') .map(|t| unquote(t.trim()).to_string()) .filter(|t| !t.is_empty()) .collect() } /// Datei-Stem ohne führenden `YYYY-MM-DD_HHMM-`-Zeitstempel — das Gegenstück /// zum Dateinamen aus `utc_stamp` + `slugify`. pub(crate) fn strip_stamp(stem: &str) -> &str { let bytes = stem.as_bytes(); let stamped = bytes.len() > 16 && bytes[..16].iter().enumerate().all(|(i, b)| match i { 4 | 7 => *b == b'-', 10 => *b == b'_', 15 => *b == b'-', _ => b.is_ascii_digit(), }); if stamped { &stem[16..] } else { stem } } /// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile. fn first_line(text: &str) -> String { let mut fallback: Option<&str> = None; for line in text.lines() { let t = line.trim(); if t.is_empty() { continue; } let h = t.trim_start_matches('#').trim(); if t.starts_with('#') && !h.is_empty() { return h.to_string(); } fallback.get_or_insert(t); } fallback.unwrap_or("entwurf").to_string() } /// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen). pub(crate) fn slugify(s: &str) -> String { let mut out = String::new(); for c in s.chars() { // Umlaute/ß transliterieren statt verschlucken („für's" → fuer-s). match c { 'ä' | 'Ä' => out.push_str("ae"), 'ö' | 'Ö' => out.push_str("oe"), 'ü' | 'Ü' => out.push_str("ue"), 'ß' => out.push_str("ss"), _ if c.is_ascii_alphanumeric() => out.push(c.to_ascii_lowercase()), _ if !out.ends_with('-') => out.push('-'), _ => {} } } let out: String = out.trim_matches('-').chars().take(60).collect(); if out.is_empty() { "entwurf".into() } else { out } } /// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`). fn utc_stamp(secs: u64) -> (String, String) { let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60); let (y, m, d) = civil_from_days((secs / 86400) as i64); ( format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"), format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"), ) } /// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus). fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719468; let era = (if z >= 0 { z } else { z - 146096 }) / 146097; let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (if m <= 2 { y + 1 } else { y }, m, d) } #[cfg(test)] mod tests { use super::*; #[test] fn utc_stamp_bekannte_zeit() { // 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC. let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15; let (stamp, iso) = utc_stamp(secs); assert_eq!(stamp, "2026-07-11_1114"); assert_eq!(iso, "2026-07-11T11:14:15Z"); } /// Zweimal Archivieren in derselben Minute mit gleichem Titel darf die erste /// Datei nicht überschreiben — der Stempel hat nur Minutenauflösung. #[test] fn gleicher_stempel_und_titel_kollidiert_nicht() { let dir = crate::domain::testutil::tmp_paths().home.join("archiv"); fs::create_dir_all(&dir).unwrap(); let erste = free_path(&dir, "2026-07-19_2118", "notiz"); assert_eq!(erste.file_name().unwrap(), "2026-07-19_2118-notiz.md"); fs::write(&erste, "alt").unwrap(); let zweite = free_path(&dir, "2026-07-19_2118", "notiz"); assert_eq!(zweite.file_name().unwrap(), "2026-07-19_2118-notiz-2.md"); fs::write(&zweite, "neu").unwrap(); assert_eq!(fs::read_to_string(&erste).unwrap(), "alt"); let dritte = free_path(&dir, "2026-07-19_2118", "notiz"); assert_eq!(dritte.file_name().unwrap(), "2026-07-19_2118-notiz-3.md"); } #[test] fn civil_from_days_referenz() { assert_eq!(civil_from_days(0), (1970, 1, 1)); assert_eq!(civil_from_days(20_645), (2026, 7, 11)); } #[test] fn slugify_grenzen() { assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen"); assert_eq!(slugify(" "), "entwurf"); assert_eq!(slugify("###"), "entwurf"); assert_eq!(slugify("Ein Test für's Archiv"), "ein-test-fuer-s-archiv"); assert_eq!(slugify("Größe ÄÖÜ"), "groesse-aeoeue"); } #[test] fn stamp_und_strip_roundtrip() { let (stamp, _) = utc_stamp(20_645u64 * 86400); assert_eq!(strip_stamp(&format!("{stamp}-adr-logging")), "adr-logging"); assert_eq!(strip_stamp("adr-logging"), "adr-logging"); assert_eq!(strip_stamp("2026-07-19-adr"), "2026-07-19-adr"); } #[test] fn frontmatter_und_parser_roundtrip() { let meta = ArchiveMeta { folder: None, description: Some("Kurz".into()), tags: vec!["adr".into(), "infra".into()], }; let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &meta); let map = parse_frontmatter(&fm); assert_eq!(map.get("title").map(String::as_str), Some("Titel")); assert_eq!(map.get("description").map(String::as_str), Some("Kurz")); assert_eq!(parse_tag_list(&map["tags"]), vec!["adr", "infra"]); } #[test] fn check_folder_relativ_ohne_punktpunkt() { assert!(check_folder("konzepte/panel").is_ok()); assert!(check_folder("../raus").is_err()); assert!(check_folder("a/../b").is_err()); assert!(check_folder("/absolut").is_err()); assert!(check_folder("").is_err()); } #[test] fn frontmatter_mit_und_ohne_meta() { let leer = ArchiveMeta::default(); let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer); assert!(fm.starts_with("---\ntitle: \"Titel\"\n")); assert!(!fm.contains("description:")); assert!(!fm.contains("tags:")); let voll = ArchiveMeta { folder: None, description: Some("Kurz \"zitiert\"".into()), tags: vec!["archiv".into(), "wiki".into()], }; let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &voll); assert!(fm.contains("description: \"Kurz 'zitiert'\"\n")); assert!(fm.contains("tags: [\"archiv\", \"wiki\"]\n")); } #[test] fn first_line_ueberschrift_oder_erste_zeile() { assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel"); assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading"); assert_eq!(first_line(" \n"), "entwurf"); } }