cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use std::fs;

use cucumber::{then, when};

use crate::CartularyWorld;

// ── When ──────────────────────────────────────────────────────────────────────

#[when(expr = "I create an issue with title {string} type {string} and body {string}")]
async fn create_issue_with_type(
    world: &mut CartularyWorld,
    title: String,
    kind: String,
    body: String,
) {
    run_create(world, &title, Some(&kind), &body, None).await;
}

#[when(expr = "I create an issue with title {string} and body {string}")]
async fn create_issue_default_type(world: &mut CartularyWorld, title: String, body: String) {
    run_create(world, &title, None, &body, None).await;
}

#[when(
    expr = "I create an issue with title {string} type {string} body {string} and blocked-by {string}"
)]
async fn create_issue_with_blocked_by(
    world: &mut CartularyWorld,
    title: String,
    kind: String,
    body: String,
    blocked_by: String,
) {
    run_create(world, &title, Some(&kind), &body, Some(&blocked_by)).await;
}

async fn run_create(
    world: &mut CartularyWorld,
    title: &str,
    kind: Option<&str>,
    body: &str,
    depends_on: Option<&str>,
) {
    let dir = world.workspace.as_ref().expect("workspace not initialized");
    let bin = assert_cmd::cargo_bin!("cartu");

    let title_words: Vec<&str> = title.split_whitespace().collect();
    let mut args = vec!["issue", "new"];
    args.extend(title_words.iter().copied());

    let tag;
    if let Some(k) = kind {
        tag = format!("flow:{k}");
        args.extend(["--tag", tag.as_str()]);
    }
    if let Some(dep) = depends_on {
        args.extend(["--blocked-by", dep]);
    }

    let output = std::process::Command::new(bin)
        .args(&args)
        .current_dir(dir.path())
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .and_then(|mut child| {
            use std::io::Write;
            if let Some(mut stdin) = child.stdin.take() {
                let _ = stdin.write_all(body.as_bytes());
            }
            child.wait_with_output()
        })
        .expect("failed to run cartu issue new");

    world.last_output = Some(output);
}

// ── Then ──────────────────────────────────────────────────────────────────────

#[then(expr = "the issue file {string} exists")]
async fn issue_file_exists(world: &mut CartularyWorld, path: String) {
    let dir = world.workspace.as_ref().expect("workspace not initialized");
    let path = resolve_path(world, &path);
    assert!(
        dir.path().join(&path).exists(),
        "expected issue file {path} to exist"
    );
}

#[then(expr = "the issue file {string} contains {string}")]
async fn issue_file_contains(world: &mut CartularyWorld, path: String, expected: String) {
    let dir = world.workspace.as_ref().expect("workspace not initialized");
    let path = resolve_path(world, &path);
    let expected = resolve_template(world, &expected);
    let content = fs::read_to_string(dir.path().join(&path))
        .unwrap_or_else(|_| panic!("issue file {path} not found"));
    assert!(
        content.contains(&expected),
        "expected issue file {path} to contain {expected:?}\n--- content ---\n{content}"
    );
}

#[then(expr = "the issue file {string} does not contain {string}")]
async fn issue_file_does_not_contain(world: &mut CartularyWorld, path: String, expected: String) {
    let dir = world.workspace.as_ref().expect("workspace not initialized");
    let path = resolve_path(world, &path);
    let content = fs::read_to_string(dir.path().join(&path))
        .unwrap_or_else(|_| panic!("issue file {path} not found"));
    assert!(
        !content.contains(&expected),
        "expected issue file {path} NOT to contain {expected:?}\n--- content ---\n{content}"
    );
}

// ── TSID-aware path resolution (ADR-0022 phase 4) ────────────────────────────
//
// After the cutover, `cartu issue new` allocates a TSID, so the on-disk
// directory is `<TSID>-<slug>/` rather than `0001-<slug>/`. Scenarios still
// write paths like `docs/issues/0001-add-login/index.md` for readability;
// `resolve_path` rewrites the leading numeric prefix to the matching TSID
// directory by scanning the parent for a single subdir whose suffix matches
// the slug. If no such directory is found the original path is returned
// unchanged so existing v3-shape fixtures (handcrafted with `Given an issue
// file ...`) keep working.

pub fn resolve_path(world: &CartularyWorld, path: &str) -> String {
    let dir = world.workspace.as_ref().expect("workspace not initialized");
    let p = std::path::Path::new(path);
    let comps: Vec<String> = p
        .components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect();
    if comps.len() < 3 {
        return path.to_string();
    }
    for i in 0..comps.len() {
        let seg = &comps[i];
        if !seg
            .chars()
            .next()
            .map(|c| c.is_ascii_digit())
            .unwrap_or(false)
        {
            continue;
        }
        let Some((num, slug)) = seg.split_once('-') else {
            break;
        };
        if !num.chars().all(|c| c.is_ascii_digit()) {
            break;
        }
        let mut parent = dir.path().to_path_buf();
        for c in &comps[..i] {
            parent.push(c);
        }
        let Ok(read) = std::fs::read_dir(&parent) else {
            break;
        };
        for entry in read.flatten() {
            let name = entry.file_name().to_string_lossy().into_owned();
            if let Some((pfx, rest)) = name.split_once('-') {
                if (pfx.len() == 13 || pfx.len() == 26) && rest == slug {
                    let mut pb = std::path::PathBuf::new();
                    for (k, c) in comps.iter().enumerate() {
                        if k == i {
                            pb.push(&name);
                        } else {
                            pb.push(c);
                        }
                    }
                    return pb.to_string_lossy().into_owned();
                }
            }
        }
        break;
    }
    path.to_string()
}

/// Substitute `{ISSUE-NNNN}` and `{ADR-NNNN}` placeholders in expectation
/// strings with the captured canonical id, when the test seeded one via
/// `Given a captured ISSUE-NNNN`. Returns the input unchanged otherwise.
pub fn resolve_template(world: &CartularyWorld, s: &str) -> String {
    let mut out = s.to_string();
    for (key, value) in &world.captured {
        out = out.replace(&format!("{{{key}}}"), value);
    }
    out
}