use std::path::Path;
pub(crate) fn build_grounding(project_root: &Path) -> Option<String> {
let layout = crate::project::ProjectLayout::new(project_root);
let cfg = crate::config::Config::load_layered(&layout.config_path()).ok()?;
let store = crate::store::Store::open(layout, &cfg).ok()?;
let h = crate::store::hierarchy::Hierarchy::load(&store).ok()?;
let book = crate::cli::resolve_user_book(&h, None, "inner grounding").ok()?;
let book_slug = book.slug.as_str();
let lx = Labels::for_language(&cfg.language);
let mut parts: Vec<String> = Vec::new();
if let Ok(cs) = crate::character::CharStore::open(project_root) {
if let Ok(decls) = cs.all_declarations(book_slug) {
let cast: Vec<String> = decls
.iter()
.map(|d| format!("{} ({})", d.character_name.trim(), d.arc_type.as_code()))
.filter(|s| !s.starts_with('('))
.collect();
if !cast.is_empty() {
let shown: Vec<String> = cast.iter().take(6).cloned().collect();
parts.push(format!("• {}: {}", lx.characters, shown.join("; ")));
}
}
}
if let Ok(ms) = crate::myth::MythStore::open(project_root) {
let mut named: Vec<String> = Vec::new();
if let Ok(symbols) = ms.symbols(book_slug) {
named.extend(
symbols
.iter()
.flat_map(|s| s.vocabulary.iter())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty()),
);
}
if let Ok(motifs) = ms.motifs(book_slug) {
named.extend(motifs.iter().map(|m| m.name.trim().to_string()).filter(|n| !n.is_empty()));
}
named.sort();
named.dedup();
if !named.is_empty() {
let shown: Vec<String> = named.iter().take(8).cloned().collect();
parts.push(format!("• {}: {}", lx.symbols, shown.join(", ")));
}
}
if let Ok(us) = crate::world::utopia::UtopiaStore::open(project_root) {
if let Ok(findings) = us.findings(book_slug, true) {
let n = findings.len();
if n > 0 {
parts.push(format!("• {}: {n}", lx.tensions));
}
}
}
let mut events: Vec<(i64, String)> = h
.flatten()
.into_iter()
.filter_map(|(n, _)| n.event.as_ref().map(|e| (n, e)))
.filter(|(n, _)| descends_from(&h, n, book.id))
.map(|(n, e)| (e.start_ticks, n.title.trim().to_string()))
.filter(|(_, t)| !t.is_empty())
.collect();
events.sort_by_key(|(t, _)| *t);
if !events.is_empty() {
let shown: Vec<String> = events.iter().take(4).map(|(_, t)| t.clone()).collect();
parts.push(format!("• {}: {}", lx.timeline, shown.join(" → ")));
}
if parts.is_empty() {
None
} else {
Some(format!("{}\n{}", lx.header, parts.join("\n")))
}
}
struct Labels {
header: &'static str,
characters: &'static str,
symbols: &'static str,
tensions: &'static str,
timeline: &'static str,
}
impl Labels {
fn for_language(cfg_language: &str) -> Labels {
match cfg_language.trim().to_lowercase().as_str() {
"ru" | "russian" | "русский" => Labels {
header: "ОСНОВА — автор уже установил следующее об этом произведении; держите текст в согласии с этим:",
characters: "Персонажи и их арки",
symbols: "Символы и мотивы",
tensions: "Открытых противоречий мира",
timeline: "Хронология истории (от ранних событий)",
},
"fr" | "french" | "français" | "francais" => Labels {
header: "ANCRAGE — l'auteur a déjà établi ceci au sujet de cette œuvre ; gardez le texte cohérent avec cela :",
characters: "Personnages et leurs arcs",
symbols: "Symboles et motifs",
tensions: "Tensions de cohérence du monde en cours",
timeline: "Chronologie du récit (du plus ancien)",
},
"de" | "german" | "deutsch" => Labels {
header: "GRUNDLAGE — der Autor hat über dieses Werk bereits Folgendes festgelegt; halte den Text damit konsistent:",
characters: "Figuren und ihre Bögen",
symbols: "Symbole und Motive",
tensions: "Offene Welt-Kohärenz-Spannungen",
timeline: "Zeitleiste der Geschichte (früheste zuerst)",
},
"es" | "spanish" | "español" | "espanol" => Labels {
header: "BASE — el autor ya ha establecido lo siguiente sobre esta obra; mantén el texto coherente con ello:",
characters: "Personajes y sus arcos",
symbols: "Símbolos y motivos",
tensions: "Tensiones de coherencia del mundo abiertas",
timeline: "Cronología del relato (de los más antiguos)",
},
_ => Labels {
header: "GROUNDING — the author has already declared the following about this work; keep the prose consistent with it:",
characters: "Characters and their arcs",
symbols: "Symbols and motifs",
tensions: "Open world-coherence tensions",
timeline: "Story timeline (earliest first)",
},
}
}
}
fn descends_from(
h: &crate::store::hierarchy::Hierarchy,
node: &crate::store::node::Node,
book_id: uuid::Uuid,
) -> bool {
let mut cur = node;
loop {
if cur.id == book_id {
return true;
}
let Some(pid) = cur.parent_id else { return false };
match h.get(pid) {
Some(p) => cur = p,
None => return false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grounding_labels_are_localized_off_the_project_language() {
assert!(Labels::for_language("russian").header.starts_with("ОСНОВА"));
assert!(Labels::for_language("ru").characters.contains("Персонажи"));
assert_eq!(Labels::for_language("french").symbols, "Symboles et motifs");
assert!(Labels::for_language("deutsch").timeline.contains("Zeitleiste"));
assert!(Labels::for_language("español").tensions.contains("Tensiones"));
assert!(Labels::for_language("english").header.starts_with("GROUNDING"));
assert!(Labels::for_language("klingon").header.starts_with("GROUNDING"));
}
#[test]
fn a_project_with_nothing_declared_grounds_to_none() {
let dir = std::env::temp_dir().join(format!("inner-ground-none-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
assert!(build_grounding(&dir).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}