tincan-cli 0.6.0

Preserve development plans, decisions, learnings, and progress in plain Markdown
use crate::util::yaml_string;

#[derive(Debug, Clone, Copy)]
pub enum Kind {
    Decision,
    Learning,
    Journal,
}

#[derive(Debug, Clone, Copy)]
pub enum DecisionStatus {
    Active,
    Superseded,
}

impl DecisionStatus {
    pub fn parse(value: &str) -> Result<Self, String> {
        match value {
            "active" => Ok(Self::Active),
            "superseded" => Ok(Self::Superseded),
            _ => Err(format!(
                "invalid decision status: {value}; expected active or superseded"
            )),
        }
    }
}

impl DecisionStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Active => "active",
            Self::Superseded => "superseded",
        }
    }
}

impl Kind {
    pub fn parse(value: &str) -> Result<Self, String> {
        match value {
            "decision" => Ok(Self::Decision),
            "learning" => Ok(Self::Learning),
            "journal" => Ok(Self::Journal),
            _ => Err(format!("unsupported record kind: {value}")),
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Decision => "decision",
            Self::Learning => "learning",
            Self::Journal => "journal",
        }
    }

    pub fn directory(self) -> &'static str {
        match self {
            Self::Decision => "decisions",
            Self::Learning => "learnings",
            Self::Journal => "journal",
        }
    }
}

pub struct Record {
    pub id: String,
    pub kind: Kind,
    pub created_at: String,
    pub statement: String,
    pub status: Option<DecisionStatus>,
    pub files: Vec<String>,
    pub topics: Vec<String>,
    pub evidence: Vec<String>,
    pub related: Vec<String>,
    pub supersedes: Vec<String>,
    pub branch: String,
}

impl Record {
    pub fn render(&self) -> String {
        let mut output = String::new();
        output.push_str("---\n");
        field(&mut output, "id", &self.id);
        field(&mut output, "type", self.kind.as_str());
        if let Some(status) = self.status {
            field(&mut output, "status", status.as_str());
        }
        field(&mut output, "created_at", &self.created_at);
        field(&mut output, "branch", &self.branch);
        list(&mut output, "files", &self.files);
        list(&mut output, "topics", &self.topics);
        list(&mut output, "related", &self.related);
        list(&mut output, "supersedes", &self.supersedes);
        output.push_str("superseded_by:\n");
        output.push_str("---\n\n");
        output.push_str("# ");
        output.push_str(&self.statement);
        output.push_str("\n\n");
        if !self.evidence.is_empty() {
            output.push_str("## Evidence\n\n");
            bullets(&mut output, &self.evidence);
        }
        output
    }
}

fn field(output: &mut String, key: &str, value: &str) {
    output.push_str(key);
    output.push_str(": ");
    output.push_str(&yaml_string(value));
    output.push('\n');
}

fn list(output: &mut String, key: &str, values: &[String]) {
    output.push_str(key);
    output.push_str(":\n");
    for value in values {
        output.push_str("  - ");
        output.push_str(&yaml_string(value));
        output.push('\n');
    }
}

fn bullets(output: &mut String, values: &[String]) {
    for value in values {
        output.push_str("- ");
        output.push_str(value);
        output.push('\n');
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_managed_frontmatter_and_an_editable_markdown_body() {
        let record = Record {
            id: "019c4ea8-7e42-7b31-a211-8df9357d747c".to_string(),
            kind: Kind::Learning,
            created_at: "2026-08-06T10:00:00Z".to_string(),
            statement: "Paging did not reduce rendering work".to_string(),
            status: None,
            files: vec!["src/gallery.rs".to_string()],
            topics: vec!["performance".to_string()],
            evidence: vec!["Release trace".to_string()],
            related: Vec::new(),
            supersedes: Vec::new(),
            branch: "main".to_string(),
        };

        let rendered = record.render();
        assert!(rendered.contains("id: \"019c4ea8-7e42-7b31-a211-8df9357d747c\""));
        assert!(rendered.contains("created_at: \"2026-08-06T10:00:00Z\""));
        assert!(rendered.contains("# Paging did not reduce rendering work"));
        assert!(rendered.contains("## Evidence\n\n- Release trace"));
        assert!(!rendered.contains("title:"));
    }
}