use std::io;
use std::path::Path;
use serde::Serialize;
use crate::check;
use crate::frontmatter;
use crate::mapfile;
pub const PURPOSE_MAX_CHARS: usize = 160;
#[derive(Debug, Serialize)]
pub struct SlotPacket {
pub map: String,
pub scope: String,
pub slot: &'static str,
pub reason: &'static str,
pub constraints: Constraints,
pub context: Context,
}
#[derive(Debug, Serialize)]
pub struct Constraints {
pub max_chars: usize,
pub style: &'static str,
}
#[derive(Debug, Serialize)]
pub struct Context {
pub top_signatures: Vec<String>,
pub children: Vec<String>,
pub tests: Vec<String>,
}
const STYLE: &str =
"one present-tense sentence saying what this unit does/owns; no 'Contains', lists, markdown";
pub fn pending(root: &Path) -> Vec<SlotPacket> {
let maps = check::load_maps(root);
let mut out = Vec::new();
for (scope, map) in &maps {
if map.slot_filled {
continue;
}
let path = mapfile::map_path(root, scope);
let Ok(doc) = std::fs::read_to_string(&path) else {
continue;
};
let Some((fm, body)) = frontmatter::parse(&doc) else {
continue;
};
let top_signatures: Vec<String> = body
.lines()
.skip_while(|l| *l != "## API")
.skip(1)
.take_while(|l| !l.starts_with("## "))
.filter_map(|line| {
let signature = line.trim_start().strip_prefix("- ")?;
let signature = signature.trim_matches('`');
if signature.starts_with("also:") || signature.starts_with('+') {
None
} else {
Some(signature.to_string())
}
})
.take(10)
.collect();
let children: Vec<String> = fm
.get_list("children")
.unwrap_or_default()
.iter()
.map(|link| {
let child_scope = crate::check::resolve_link(scope, link).unwrap_or_default();
let child_path = mapfile::map_path(root, &child_scope);
let purpose = std::fs::read_to_string(&child_path)
.ok()
.and_then(|d| frontmatter::parse(&d).map(|(_, b)| b.to_string()))
.and_then(|b| mapfile::slot_text(&b, "purpose"));
match purpose {
Some(p) => format!("{link} - {p}"),
None => link.clone(),
}
})
.collect();
let tests: Vec<String> = body
.lines()
.skip_while(|l| *l != "## Tests")
.skip(1)
.take_while(|l| !l.starts_with("## "))
.filter(|l| l.starts_with("- "))
.map(|l| l.trim_start_matches("- ").trim_matches('`').to_string())
.collect();
let map_rel = if scope.is_empty() {
"MAP.md".to_string()
} else {
format!("{scope}/MAP.md")
};
out.push(SlotPacket {
map: map_rel,
scope: if scope.is_empty() {
".".into()
} else {
scope.clone()
},
slot: "purpose",
reason: "new",
constraints: Constraints {
max_chars: PURPOSE_MAX_CHARS,
style: STYLE,
},
context: Context {
top_signatures,
children,
tests,
},
});
}
out
}
pub fn render_prompt(p: &SlotPacket) -> String {
let mut s = format!(
"Purpose for `{}` MAP.\n\
Max {} chars; {}.\n",
p.scope, p.constraints.max_chars, p.constraints.style
);
if !p.context.top_signatures.is_empty() {
s.push_str("API:\n");
for sig in &p.context.top_signatures {
s.push_str(&format!("- {sig}\n"));
}
}
if !p.context.children.is_empty() {
s.push_str(&format!("Children: {}\n", p.context.children.join(", ")));
}
if !p.context.tests.is_empty() {
s.push_str(&format!("Tests: {}\n", p.context.tests.join(", ")));
}
s.push_str("Return only the sentence.\n");
s
}
pub fn validate(text: &str) -> Result<String, String> {
let collapsed: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.is_empty() {
return Err("empty slot text".into());
}
if collapsed.chars().count() > PURPOSE_MAX_CHARS {
return Err(format!(
"slot text is {} chars (max {PURPOSE_MAX_CHARS})",
collapsed.chars().count()
));
}
if collapsed.contains('#') || collapsed.contains("```") {
return Err("no markdown in slot text".into());
}
if collapsed.to_lowercase().contains("todo") {
return Err("no placeholders in slot text".into());
}
Ok(collapsed)
}
pub fn fill(root: &Path, map_rel: &str, text: &str) -> io::Result<()> {
let sentence = validate(text).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let path = root.join(map_rel);
let doc = std::fs::read_to_string(&path)?;
let Some((mut fm, body)) = frontmatter::parse(&doc) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{map_rel} has no radar frontmatter"),
));
};
let open_tag = "<!-- radar:slot purpose";
let Some(start) = body.find(open_tag) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{map_rel} has no purpose slot"),
));
};
let after_open = body[start..]
.find("-->")
.map(|i| start + i + 3)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "malformed slot marker"))?;
let close = body[after_open..]
.find("<!-- /radar:slot -->")
.map(|i| after_open + i)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "unterminated slot"))?;
let new_body = format!("{}\n{}\n{}", &body[..after_open], sentence, &body[close..]);
fm.set(
"tokens",
format!("~{}", mapfile::approx_tokens(new_body.len())),
);
fm.set("stamped", mapfile::now_iso());
std::fs::write(&path, fm.render() + &new_body)?;
crate::initcmd::sync_embedded_root_map(root);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_normalizes_and_rejects() {
assert_eq!(
validate(" Verifies tokens. ").unwrap(),
"Verifies tokens."
);
assert!(validate("").is_err());
assert!(validate(&"x".repeat(200)).is_err());
assert!(validate("# heading").is_err());
assert!(validate("TODO write me").is_err());
}
#[test]
fn prompt_keeps_constraints_and_context_compactly() {
let packet = SlotPacket {
map: "MAP.md".into(),
scope: ".".into(),
slot: "purpose",
reason: "new",
constraints: Constraints {
max_chars: PURPOSE_MAX_CHARS,
style: STYLE,
},
context: Context {
top_signatures: vec!["pub fn run()".into()],
children: vec!["src/MAP.md - Implements source routing.".into()],
tests: vec!["tests/cli_smoke.rs".into()],
},
};
let prompt = render_prompt(&packet);
assert!(prompt.contains("Purpose for `.` MAP."));
assert!(prompt.contains("Max 160 chars; one present-tense sentence"));
assert!(prompt.contains("API:\n- pub fn run()"));
assert!(prompt.contains("Children: src/MAP.md"));
assert!(prompt.contains("Tests: tests/cli_smoke.rs"));
assert!(prompt.contains("Return only the sentence."));
}
}