use anyhow::{Context, Result};
use serde::Deserialize;
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
pub struct Campaign {
#[serde(default)]
pub synthesist_trees: Vec<String>,
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Session {
pub campaign: String,
#[serde(default)]
pub entrypoint: String,
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let trimmed = content.trim_start_matches('\u{feff}');
let after_opening = trimmed.strip_prefix("---")?;
let after_opening = after_opening.trim_start_matches('\r').strip_prefix('\n')?;
let end_marker = after_opening.find("\n---")?;
let fm = &after_opening[..end_marker];
let rest = &after_opening[end_marker + 4..];
let body = rest.strip_prefix("\r\n").unwrap_or_else(|| rest.strip_prefix('\n').unwrap_or(rest));
Some((fm, body))
}
pub fn load_campaign(path: &Path) -> Result<(Campaign, String)> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read campaign file: {}", path.display()))?;
let (fm, body) = split_frontmatter(&content).with_context(|| {
format!("No YAML frontmatter in {}", path.display())
})?;
let campaign: Campaign = serde_yaml_ng::from_str(fm).with_context(|| {
format!("Failed to parse campaign frontmatter: {}", path.display())
})?;
Ok((campaign, body.to_string()))
}
pub fn load_session(path: &Path) -> Result<(Session, String)> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read session file: {}", path.display()))?;
let (fm, body) = split_frontmatter(&content).with_context(|| {
format!("No YAML frontmatter in {}", path.display())
})?;
let session: Session = serde_yaml_ng::from_str(fm).with_context(|| {
format!("Failed to parse session frontmatter: {}", path.display())
})?;
Ok((session, body.to_string()))
}
pub fn campaign_file(harness_dir: &Path, campaign: &str) -> Result<PathBuf> {
let path = harness_dir
.join("campaigns")
.join(campaign)
.join("campaign.md");
if !path.is_file() {
anyhow::bail!(
"Campaign '{campaign}' not found at {}.",
path.display()
);
}
Ok(path)
}
pub fn scaffold_campaign_interactive(
harness_dir: &Path,
campaign: &str,
) -> Result<PathBuf> {
eprintln!();
eprintln!("Campaign '{campaign}' does not exist in this harness.");
eprint!("Create it? [Y/n] ");
io::stderr().flush().ok();
let mut response = String::new();
io::stdin().lock().read_line(&mut response)?;
let response = response.trim().to_lowercase();
if !response.is_empty() && response != "y" && response != "yes" {
anyhow::bail!("Scaffolding declined. Cannot launch without a campaign.");
}
eprintln!();
eprintln!("Paths (comma-separated, absolute, supports ~)");
eprint!(" e.g. ~/gitlab.com/nomograph/gkg,~/gitlab.com/gitlab-org/gkg: ");
io::stderr().flush().ok();
let mut paths_line = String::new();
io::stdin().lock().read_line(&mut paths_line)?;
let paths: Vec<String> = paths_line
.trim()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
eprint!("Synthesist tree (blank for none): ");
io::stderr().flush().ok();
let mut tree = String::new();
io::stdin().lock().read_line(&mut tree)?;
let tree = tree.trim().to_string();
eprint!("One-sentence description (blank to edit later): ");
io::stderr().flush().ok();
let mut desc = String::new();
io::stdin().lock().read_line(&mut desc)?;
let desc = desc.trim().to_string();
let campaign_dir = harness_dir.join("campaigns").join(campaign);
fs::create_dir_all(campaign_dir.join("sessions"))?;
let trees_yaml = if tree.is_empty() {
"synthesist_trees: []\n".to_string()
} else {
format!("synthesist_trees:\n - {tree}\n")
};
let paths_yaml = if paths.is_empty() {
"paths: []\n".to_string()
} else {
let list = paths
.iter()
.map(|p| format!(" - {p}"))
.collect::<Vec<_>>()
.join("\n");
format!("paths:\n{list}\n")
};
let desc_line = if desc.is_empty() {
"(edit me)".to_string()
} else {
desc
};
let content = format!(
"---\n{trees_yaml}{paths_yaml}---\n\n# {campaign}\n\n## What this is\n{desc_line}\n\n## How to behave\n- (edit me)\n"
);
let campaign_md = campaign_dir.join("campaign.md");
fs::write(&campaign_md, content)?;
eprintln!();
eprintln!("Scaffolded campaign at {}", campaign_md.display());
eprintln!("Edit it any time to refine paths, tree, or guide.");
eprintln!();
Ok(campaign_md)
}
pub fn resolve_or_scaffold_session(
harness_dir: &Path,
campaign: &str,
date: &str,
) -> Result<PathBuf> {
let campaign_dir = harness_dir.join("campaigns").join(campaign);
let sessions_dir = campaign_dir.join("sessions");
let plain = sessions_dir.join(format!("{date}.md"));
if plain.is_file() {
return Ok(plain);
}
if sessions_dir.is_dir()
&& let Some(suffixed) = first_matching_session(&sessions_dir, date)?
{
return Ok(suffixed);
}
fs::create_dir_all(&sessions_dir)?;
let template_path = harness_dir
.join("campaigns")
.join("TEMPLATE")
.join("sessions")
.join("TEMPLATE.md");
let content = if template_path.is_file() {
let tpl = fs::read_to_string(&template_path)?;
tpl.replace("<slug>", campaign)
.replace("<date>[-<suffix>]", date)
} else {
format!(
"---\ncampaign: {campaign}\nentrypoint: \"\"\n---\n\n# Session {date}\n\n## {date}\n\n"
)
};
fs::write(&plain, content)?;
Ok(plain)
}
fn first_matching_session(sessions_dir: &Path, date: &str) -> Result<Option<PathBuf>> {
let mut candidates: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(sessions_dir)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if !name_str.ends_with(".md") {
continue;
}
let exact = format!("{date}.md");
let with_suffix = format!("{date}-");
if name_str == exact || name_str.starts_with(&with_suffix) {
candidates.push(entry.path());
}
}
candidates.sort();
Ok(candidates.into_iter().next())
}
pub fn compose_prompt(campaign: &str, campaign_body: &str, session_body: &str) -> String {
format!(
"# Campaign: {campaign}\n\n{}\n\n---\n\n# Session\n\n{}",
campaign_body.trim(),
session_body.trim()
)
}
pub fn expand_home(path: &str) -> String {
shellexpand::tilde(path).to_string()
}
pub fn today() -> String {
chrono::Local::now().format("%Y-%m-%d").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_frontmatter_basic() {
let content = "---\nfoo: bar\n---\nbody text\n";
let (fm, body) = split_frontmatter(content).unwrap();
assert_eq!(fm, "foo: bar");
assert_eq!(body, "body text\n");
}
#[test]
fn split_frontmatter_multiline() {
let content = "---\nfoo: bar\nbaz: qux\n---\n\n# Title\n\nMore body.\n";
let (fm, body) = split_frontmatter(content).unwrap();
assert_eq!(fm, "foo: bar\nbaz: qux");
assert!(body.starts_with("\n# Title") || body.starts_with("# Title"));
}
#[test]
fn split_frontmatter_missing_returns_none() {
let content = "no frontmatter here\n";
assert!(split_frontmatter(content).is_none());
}
#[test]
fn parse_campaign_frontmatter() {
let fm = "synthesist_trees:\n - harness\npaths:\n - ~/foo\n - ~/bar\n";
let c: Campaign = serde_yaml_ng::from_str(fm).unwrap();
assert_eq!(c.synthesist_trees, vec!["harness"]);
assert_eq!(c.paths, vec!["~/foo".to_string(), "~/bar".to_string()]);
}
#[test]
fn parse_session_frontmatter() {
let fm = "campaign: harness\nentrypoint: do the thing\n";
let s: Session = serde_yaml_ng::from_str(fm).unwrap();
assert_eq!(s.campaign, "harness");
assert_eq!(s.entrypoint, "do the thing");
}
#[test]
fn parse_campaign_defaults_to_empty_lists() {
let fm = "";
let c: Campaign = serde_yaml_ng::from_str(fm).unwrap_or_default();
assert!(c.synthesist_trees.is_empty());
assert!(c.paths.is_empty());
}
#[test]
fn compose_prompt_includes_both_bodies() {
let out = compose_prompt("gkg", "## What\ngkg stuff", "## Log\nentry");
assert!(out.contains("Campaign: gkg"));
assert!(out.contains("gkg stuff"));
assert!(out.contains("# Session"));
assert!(out.contains("entry"));
}
#[test]
fn today_has_iso_shape() {
let t = today();
assert_eq!(t.len(), 10);
assert!(t.chars().filter(|c| *c == '-').count() == 2);
}
#[test]
fn resolve_or_scaffold_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let harness_dir = tmp.path();
let campaign_dir = harness_dir.join("campaigns").join("gkg");
fs::create_dir_all(&campaign_dir).unwrap();
fs::write(
campaign_dir.join("campaign.md"),
"---\npaths: []\n---\n\n# gkg\n",
)
.unwrap();
let path = resolve_or_scaffold_session(harness_dir, "gkg", "2026-04-24").unwrap();
assert!(path.exists());
assert_eq!(
path.file_name().unwrap().to_str().unwrap(),
"2026-04-24.md"
);
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("campaign: gkg"));
}
#[test]
fn resolve_or_scaffold_prefers_suffixed_same_day() {
let tmp = tempfile::tempdir().unwrap();
let harness_dir = tmp.path();
let sessions_dir = harness_dir.join("campaigns").join("gkg").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
fs::write(
sessions_dir.join("2026-04-24-cicd.md"),
"---\ncampaign: gkg\nentrypoint: x\n---\n\n",
)
.unwrap();
let path = resolve_or_scaffold_session(harness_dir, "gkg", "2026-04-24").unwrap();
assert_eq!(
path.file_name().unwrap().to_str().unwrap(),
"2026-04-24-cicd.md"
);
}
}