ccd-cli 1.0.0-beta.2

Bootstrap and validate Continuous Context Development repositories
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;

use anyhow::{bail, Context, Result};
use toml::Value;

use crate::memory::entries::StructuredMemoryEntry;

const OPENING_FENCE: &str = "```ccd-memory";
const CLOSING_FENCE: &str = "```";

pub fn append_block_to_contents(contents: &str, entry_block: &str) -> String {
    if contents.trim().is_empty() {
        return format!("{entry_block}\n");
    }

    format!(
        "{}\n\n{entry_block}\n",
        contents.trim_end_matches(['\n', '\r'])
    )
}

pub fn rewrite_entry_blocks(
    contents: &str,
    replacements: &HashMap<String, String>,
) -> Result<String> {
    let actions = replacements
        .iter()
        .map(|(id, replacement)| (id.clone(), Some(replacement.clone())))
        .collect::<HashMap<_, _>>();
    rewrite_with_actions(contents, &actions)
}

pub fn remove_entry_blocks(contents: &str, entry_ids: &[String]) -> Result<String> {
    let actions = entry_ids
        .iter()
        .cloned()
        .map(|id| (id, None))
        .collect::<HashMap<_, _>>();
    rewrite_with_actions(contents, &actions)
}

pub fn render_entry_block(entry: &StructuredMemoryEntry) -> String {
    let mut lines = vec![
        "```ccd-memory".to_owned(),
        format!("id = {}", toml_string(&entry.id)),
        format!("type = {}", toml_string(&entry.entry_type)),
        format!("state = {}", toml_string(&entry.state)),
        format!("created_at = {}", toml_string(&entry.created_at)),
        format!("last_touched_session = {}", entry.last_touched_session),
        format!("origin = {}", toml_string(&entry.origin)),
    ];

    if let Some(superseded_at) = &entry.superseded_at {
        lines.push(format!("superseded_at = {}", toml_string(superseded_at)));
    }
    if let Some(decay_class) = &entry.decay_class {
        lines.push(format!("decay_class = {}", toml_string(decay_class)));
    }
    if let Some(expires_at) = &entry.expires_at {
        lines.push(format!("expires_at = {}", toml_string(expires_at)));
    }
    if !entry.tags.is_empty() {
        lines.push(format!("tags = {}", toml_string_array(&entry.tags)));
    }
    if let Some(source_ref) = &entry.source_ref {
        lines.push(format!("source_ref = {}", toml_string(source_ref)));
    }
    if !entry.supersedes.is_empty() {
        lines.push(format!(
            "supersedes = {}",
            toml_string_array(&entry.supersedes)
        ));
    }
    lines.push(format!("content = {}", toml_string(&entry.content)));
    lines.push("```".to_owned());
    lines.join("\n")
}

pub fn write_memory_file(path: &Path, contents: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create directory {}", parent.display()))?;
    }

    fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
}

fn parsed_block_id(payload: &str) -> Result<Option<String>> {
    let raw = toml::from_str::<Value>(payload)
        .context("failed to reparse validated `ccd-memory` block while rewriting memory file")?;
    let Some(table) = raw.as_table() else {
        bail!("validated `ccd-memory` block payload must remain a TOML table")
    };

    Ok(table
        .get("id")
        .and_then(Value::as_str)
        .map(ToOwned::to_owned))
}

fn rewrite_with_actions(
    contents: &str,
    actions: &HashMap<String, Option<String>>,
) -> Result<String> {
    if actions.is_empty() {
        return Ok(contents.to_owned());
    }

    let mut result = String::with_capacity(contents.len());
    let mut matched = HashSet::<String>::new();
    let mut offset = 0usize;
    let mut cursor = 0usize;
    let mut active_start = None::<usize>;
    let mut payload = String::new();

    for segment in contents.split_inclusive('\n') {
        let line = segment.trim_end_matches(['\n', '\r']);
        let trimmed = line.trim();

        if let Some(start) = active_start {
            if trimmed == CLOSING_FENCE {
                let end = offset + segment.len();
                let block_id = parsed_block_id(&payload)?;
                if let Some(block_id) = block_id {
                    if let Some(action) = actions.get(&block_id) {
                        result.push_str(&contents[cursor..start]);
                        if let Some(replacement) = action {
                            result.push_str(replacement);
                        }
                        matched.insert(block_id);
                        cursor = end;
                    }
                }

                active_start = None;
                payload.clear();
                offset = end;
                continue;
            }

            if !payload.is_empty() {
                payload.push('\n');
            }
            payload.push_str(line);
            offset += segment.len();
            continue;
        }

        if trimmed.starts_with(OPENING_FENCE) {
            active_start = Some(offset);
        }

        offset += segment.len();
    }

    if active_start.is_some() {
        bail!("unterminated `ccd-memory` block encountered while rewriting memory file")
    }

    result.push_str(&contents[cursor..]);

    let missing = actions
        .keys()
        .filter(|id| !matched.contains(*id))
        .cloned()
        .collect::<Vec<_>>();
    if !missing.is_empty() {
        bail!(
            "failed to rewrite expected structured memory entries: {}",
            missing.join(", ")
        );
    }

    Ok(result)
}

fn toml_string(value: &str) -> String {
    Value::String(value.to_owned()).to_string()
}

fn toml_string_array(values: &[String]) -> String {
    Value::Array(
        values
            .iter()
            .cloned()
            .map(Value::String)
            .collect::<Vec<_>>(),
    )
    .to_string()
}

#[cfg(test)]
mod tests {
    use super::{remove_entry_blocks, render_entry_block};
    use crate::memory::entries::StructuredMemoryEntry;

    #[test]
    fn renders_lifecycle_metadata_in_structured_order() {
        let entry = StructuredMemoryEntry {
            id: "mem_rule".to_owned(),
            entry_type: "rule".to_owned(),
            state: "superseded".to_owned(),
            created_at: "2026-03-10T10:00:00Z".to_owned(),
            last_touched_session: 12,
            origin: "manual".to_owned(),
            superseded_at: Some("2026-03-12T09:30:00Z".to_owned()),
            decay_class: Some("stable".to_owned()),
            expires_at: Some("2026-12-31T23:59:59Z".to_owned()),
            tags: vec!["memory".to_owned()],
            source_ref: None,
            supersedes: Vec::new(),
            content: "Prefer deterministic writes.".to_owned(),
        };

        let rendered = render_entry_block(&entry);
        let superseded_at = rendered.find("superseded_at = ").unwrap();
        let decay_class = rendered.find("decay_class = ").unwrap();
        let expires_at = rendered.find("expires_at = ").unwrap();

        assert!(superseded_at < decay_class);
        assert!(decay_class < expires_at);
    }

    #[test]
    fn removes_selected_entry_blocks() {
        let keep = StructuredMemoryEntry {
            id: "mem_keep".to_owned(),
            entry_type: "rule".to_owned(),
            state: "active".to_owned(),
            created_at: "2026-03-10T10:00:00Z".to_owned(),
            last_touched_session: 12,
            origin: "manual".to_owned(),
            superseded_at: None,
            decay_class: None,
            expires_at: None,
            tags: Vec::new(),
            source_ref: None,
            supersedes: Vec::new(),
            content: "Keep me.".to_owned(),
        };
        let drop = StructuredMemoryEntry {
            id: "mem_drop".to_owned(),
            entry_type: "rule".to_owned(),
            state: "promotion_candidate".to_owned(),
            created_at: "2026-03-10T10:00:00Z".to_owned(),
            last_touched_session: 12,
            origin: "manual".to_owned(),
            superseded_at: None,
            decay_class: None,
            expires_at: Some("2026-03-11T00:00:00Z".to_owned()),
            tags: Vec::new(),
            source_ref: None,
            supersedes: Vec::new(),
            content: "Drop me.".to_owned(),
        };

        let contents = format!(
            "{}\n\n{}\n",
            render_entry_block(&keep),
            render_entry_block(&drop)
        );
        let rewritten = remove_entry_blocks(&contents, &[drop.id.clone()]).unwrap();

        assert!(rewritten.contains("mem_keep"));
        assert!(!rewritten.contains("mem_drop"));
    }
}