b9d006184a
- Projekt-Config zieht von ai-control.json nach .ai-control/config.json um, das Icon liegt daneben und synct mit dem Projekt. - Jedes Projekt trägt eine UUID (id) und den Anzeigenamen (name) in der Config; die UUID ist Registry-Schlüssel und project-Parameter aller Commands, der Name reine Darstellung. - Pool-Zuordnung ist maschinenlokal und liegt in der projects.json-Registry, nicht mehr in der syncbaren Projekt-Config. - open_terminal prüft die ID im Ordner gegen die Registry (verschobene oder ersetzte Ordner scheitern laut). - Import übernimmt eine mitgebrachte ID, legt Arbeitsordner und Archiv-Home an und zieht die Archiv-Permission nach. - Migration beim App-Start: Alt-Datei umziehen, Pool in die Registry, Icons aus ~/.config/ai-control/icons ins Projekt, Registry auf UUIDs. - ProjectConfig reicht unbekannte Keys per serde(flatten) durch den Round-Trip (Regressionstest config_roundtrip_erhaelt_fremde_keys).
469 lines
17 KiB
Rust
469 lines
17 KiB
Rust
//! 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<PathBuf> {
|
|
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<PathBuf, String> {
|
|
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<String>,
|
|
/// Einzeiler fürs Frontmatter.
|
|
pub(crate) description: Option<String>,
|
|
/// Schlagwörter fürs Frontmatter.
|
|
pub(crate) tags: Vec<String>,
|
|
}
|
|
|
|
/// 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<PathBuf, String> {
|
|
// 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<String> =
|
|
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<String, String> {
|
|
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<String> {
|
|
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");
|
|
}
|
|
}
|