nomograph-muxr 1.0.4

Tmux session manager for AI coding workflows
//! Campaign/session primitives for muxr-managed harnesses.
//!
//! A campaign is a long-lived body of work. Lives at
//! `campaigns/<slug>/campaign.md` with YAML frontmatter declaring
//! `synthesist_trees:` and `paths:`, and a markdown body of conventions.
//!
//! A session is an ephemeral episode. Lives at
//! `campaigns/<slug>/sessions/<date>[-<suffix>].md` with YAML
//! frontmatter declaring `campaign:` + `entrypoint:`, and a markdown
//! body that is an append-only log.
//!
//! Muxr composes `HARNESS.md` + campaign body + session body into the
//! runtime's system prompt at launch. Campaign `paths:` are passed as
//! `--add-dir`, so Claude knows the full work surface.

use anyhow::{Context, Result};
use serde::Deserialize;
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};

/// Campaign frontmatter (`campaigns/<slug>/campaign.md`).
#[derive(Debug, Default, Deserialize)]
pub struct Campaign {
    #[serde(default)]
    pub synthesist_trees: Vec<String>,
    #[serde(default)]
    pub paths: Vec<String>,
}

/// Session frontmatter (`campaigns/<slug>/sessions/<date>[-<suffix>].md`).
#[derive(Debug, Deserialize)]
pub struct Session {
    pub campaign: String,
    #[serde(default)]
    pub entrypoint: String,
}

/// Split a markdown file into (YAML frontmatter, markdown body).
///
/// Expects the file to start with `---`, a YAML block, then a line that
/// is just `---`. Everything after is the body.
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()))
}

/// Resolve `<harness-dir>/campaigns/<campaign>/campaign.md`, erroring if
/// the campaign does not exist.
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)
}

/// Interactively scaffold a new campaign via stdin prompts.
///
/// Writes `campaigns/<campaign>/campaign.md` and creates the sibling
/// `sessions/` directory. Returns the path to the new campaign.md.
/// Called from muxr launch when the requested campaign doesn't exist,
/// so the human can create it in-flow rather than editing files first.
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();

    // Write campaign.md
    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)
}

/// Find or scaffold a session file for the given campaign and date.
/// If a file at `campaigns/<campaign>/sessions/<date>.md` exists, returns
/// it. Otherwise scaffolds from `campaigns/TEMPLATE/sessions/TEMPLATE.md`
/// (or a built-in fallback) and returns the new path.
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");

    // If a same-date file already exists, prefer the plain one; if only
    // suffixed variants exist (e.g. 2026-04-24-cicd.md), return the first
    // one found so muxr attaches to ongoing work.
    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);
    }

    // Not found: scaffold at <date>.md
    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)
}

/// Find the first `<date>[-<suffix>].md` file in `sessions_dir` whose
/// basename begins with `<date>`. Skips files in `archive/`.
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())
}

/// Compose the system prompt addition from campaign + session bodies.
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()
    )
}

/// Expand `~` in a path string to the user's home directory.
pub fn expand_home(path: &str) -> String {
    shellexpand::tilde(path).to_string()
}

/// Today's date as `YYYY-MM-DD`.
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"
        );
    }
}