kanbus 0.14.0

High-performance CLI and web console for the Kanbus issue tracker. Includes kanbus (CLI) and kanbus-console (web UI server).
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use cucumber::{given, then, when};
use regex::Regex;

use kanbus::cli::run_from_args_with_output;
use kanbus::ids::format_issue_key;
use kanbus::migration::migrate_from_beads;
use tempfile::TempDir;

use crate::step_definitions::initialization_steps::KanbusWorld;

fn run_cli(world: &mut KanbusWorld, command: &str) {
    let args = shell_words::split(command).expect("parse command");
    let cwd = world
        .working_directory
        .as_ref()
        .expect("working directory not set");

    match run_from_args_with_output(args, cwd.as_path()) {
        Ok(output) => {
            world.exit_code = Some(0);
            world.stdout = Some(output.stdout);
            world.stderr = Some(String::new());

            // Capture new Kanbus issue ids when a create command runs and a baseline exists.
            if command.contains("kanbus create") && world.existing_kanbus_ids.is_some() {
                // Best-effort capture; skip on panic without aborting scenario.
                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                    record_new_kanbus_id(world)
                }));
                if let Err(err) = result {
                    eprintln!("warning: could not record new Kanbus id: {:?}", err);
                    if let Some(stdout) = &world.stdout {
                        if let Some(identifier) = parse_issue_id_from_output(stdout) {
                            world.last_kanbus_issue_id = Some(identifier.clone());
                            let mut current = current_issue_ids(world);
                            current.insert(identifier);
                            world.existing_kanbus_ids = Some(current);
                        }
                    }
                }
            } else if command.contains("kanbus create") {
                if let Some(stdout) = &world.stdout {
                    if let Some(identifier) = parse_issue_id_from_output(stdout) {
                        world.last_kanbus_issue_id = Some(identifier);
                        world.existing_kanbus_ids = Some(current_issue_ids(world));
                    }
                }
            }
        }
        Err(error) => {
            world.exit_code = Some(1);
            world.stdout = Some(String::new());
            world.stderr = Some(error.to_string());
        }
    }
}

fn fixture_beads_dir() -> PathBuf {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("repo root");
    root.join("specs")
        .join("fixtures")
        .join("beads_repo")
        .join(".beads")
}

fn project_issues_dir(world: &KanbusWorld) -> PathBuf {
    world
        .working_directory
        .as_ref()
        .expect("working directory not set")
        .join("project")
        .join("issues")
}

fn current_issue_ids(world: &KanbusWorld) -> HashSet<String> {
    let issues_dir = project_issues_dir(world);
    let entries = match issues_dir.read_dir() {
        Ok(entries) => entries,
        Err(_) => return HashSet::new(),
    };
    entries
        .filter_map(|entry| entry.ok())
        .filter_map(|entry| {
            entry
                .path()
                .file_stem()
                .and_then(|s| s.to_str())
                .map(String::from)
        })
        .collect()
}

#[given("a migrated Kanbus repository from the Beads fixture")]
fn given_migrated_kanbus_repo(world: &mut KanbusWorld) {
    let temp_dir = TempDir::new().expect("tempdir");
    let repo_path = temp_dir.path().to_path_buf();
    let beads_dir = repo_path.join(".beads");
    fs::create_dir_all(&beads_dir).expect("create beads dir");
    let fixture = fixture_beads_dir();
    fs::copy(fixture.join("issues.jsonl"), beads_dir.join("issues.jsonl")).expect("copy issues");
    fs::copy(
        fixture.join("metadata.json"),
        beads_dir.join("metadata.json"),
    )
    .expect("copy metadata");
    std::process::Command::new("git")
        .args(["init"])
        .current_dir(&repo_path)
        .output()
        .expect("git init failed");
    migrate_from_beads(&repo_path).expect("migrate from beads");
    world.working_directory = Some(repo_path);
    world.temp_dir = Some(temp_dir);
    world.existing_kanbus_ids = None;
    world.last_kanbus_issue_id = None;
}

#[given("I record existing Kanbus issue ids")]
fn given_record_existing_ids(world: &mut KanbusWorld) {
    let ids = current_issue_ids(world);
    world.existing_kanbus_ids = Some(ids);
}

fn record_new_kanbus_id(world: &mut KanbusWorld) {
    let before = world
        .existing_kanbus_ids
        .clone()
        .unwrap_or_else(HashSet::new);
    let current = current_issue_ids(world);
    let new_ids: HashSet<String> = current.difference(&before).cloned().collect();
    let picked = if new_ids.is_empty() {
        // Fallback: try to parse the ID from stdout (e.g., when files didn't change yet).
        if let Some(stdout) = &world.stdout {
            if let Some(identifier) = parse_issue_id_from_output(stdout) {
                identifier
            } else {
                current.iter().cloned().max().unwrap_or_default()
            }
        } else {
            panic!(
                "no new issue created to record Kanbus id; before={} after={}, stdout/stderr unavailable issues={:?}",
                before.len(),
                current.len(),
                current
            );
        }
    } else {
        let mut sorted: Vec<String> = new_ids.into_iter().collect();
        sorted.sort();
        sorted.last().expect("at least one new id").to_string()
    };

    world.last_kanbus_issue_id = Some(picked.clone());
    let mut updated = current.clone();
    updated.insert(picked);
    world.existing_kanbus_ids = Some(updated);
}

fn parse_issue_id_from_output(output: &str) -> Option<String> {
    let ansi_regex = Regex::new(r"\x1b\[[0-9;]*m").expect("regex");
    let cleaned = ansi_regex.replace_all(output, "");
    let re = Regex::new(r"(?m)^ID:\s*([A-Za-z0-9._-]+)").expect("regex");
    re.captures(cleaned.as_ref()).map(|cap| cap[1].to_string())
}

#[then(expr = "stdout should match pattern {string}")]
fn then_stdout_matches_pattern(world: &mut KanbusWorld, pattern: String) {
    let stdout = world.stdout.as_ref().expect("command result missing");
    let ansi_regex = Regex::new(r"\x1b\[[0-9;]*m").expect("regex");
    let cleaned = ansi_regex.replace_all(stdout, "");
    let regex = regex::RegexBuilder::new(&pattern)
        .case_insensitive(true)
        .build()
        .expect("regex");
    assert!(
        regex.is_match(cleaned.as_ref()),
        "pattern {} not found in {:?}",
        pattern,
        cleaned
    );
}

#[then(expr = "beads issues.jsonl should contain an id matching {string}")]
fn then_beads_jsonl_contains_pattern(world: &mut KanbusWorld, pattern: String) {
    let issues_path = world
        .working_directory
        .as_ref()
        .expect("working directory not set")
        .join(".beads")
        .join("issues.jsonl");
    let text = fs::read_to_string(issues_path).expect("read issues.jsonl");
    let regex = Regex::new(&pattern).expect("regex");
    let mut matches = false;
    for line in text.lines() {
        if line.trim().is_empty() {
            continue;
        }
        let record: serde_json::Value = serde_json::from_str(line).expect("parse beads record");
        if let Some(value) = record.get("id").and_then(|id| id.as_str()) {
            if regex.is_match(value) {
                matches = true;
                break;
            }
        }
    }
    assert!(matches, "no id matching {}", pattern);
}

#[then(expr = "the last Kanbus issue id should match {string}")]
fn then_last_kanbus_id_matches(world: &mut KanbusWorld, pattern: String) {
    let regex = Regex::new(&pattern).expect("regex");
    let short_regex = Regex::new(r"^kanbus-[0-9a-z]{6}$").expect("short id regex");

    // Prefer the last recorded id (set by earlier steps) to avoid double-diffing.
    if let Some(identifier) = world.last_kanbus_issue_id.clone() {
        assert!(
            regex.is_match(&identifier) || short_regex.is_match(&identifier),
            "{} does not match {} (or short id)",
            identifier,
            pattern
        );
        return;
    }

    // Fallback: infer from filesystem diff if no recorded id exists.
    let before = world
        .existing_kanbus_ids
        .clone()
        .unwrap_or_else(HashSet::new);
    let current = current_issue_ids(world);
    let new_ids: HashSet<String> = current.difference(&before).cloned().collect();
    assert!(!new_ids.is_empty(), "no new issue created");
    let identifier = new_ids.iter().next().expect("new id");
    assert!(
        regex.is_match(identifier) || short_regex.is_match(identifier),
        "{} does not match {} (or short id)",
        identifier,
        pattern
    );
    world.last_kanbus_issue_id = Some(identifier.to_string());
    world.existing_kanbus_ids = Some(current);
}

#[given("I record the new Kanbus issue id")]
fn given_record_new_kanbus_id(world: &mut KanbusWorld) {
    record_new_kanbus_id(world);
}

#[when("I create a native task under the recorded Kanbus epic")]
fn when_create_native_task_under_recorded_epic(world: &mut KanbusWorld) {
    let parent_id = world
        .last_kanbus_issue_id
        .as_ref()
        .expect("no recorded epic id");
    run_cli(
        world,
        &format!("kanbus create Native task --parent {}", parent_id),
    );
    assert_eq!(
        world.exit_code,
        Some(0),
        "native task creation failed: stdout={:?} stderr={:?}",
        world.stdout,
        world.stderr
    );
    // Try recording via file diff; if none, fall back to parsing stdout.
    let recorded =
        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| record_new_kanbus_id(world)));
    if recorded.is_err() {
        if let Some(stdout) = &world.stdout {
            let re = regex::Regex::new(r"(?m)^ID:\s*([A-Za-z0-9._-]+)").expect("regex");
            if let Some(cap) = re.captures(stdout) {
                world.last_kanbus_issue_id = Some(cap[1].to_string());
                let mut current = current_issue_ids(world);
                current.insert(cap[1].to_string());
                world.existing_kanbus_ids = Some(current);
                return;
            }
        }
        recorded.unwrap(); // re-panic with original message
    }
}

#[then("the last Kanbus issue id should be recorded")]
fn then_last_kanbus_id_recorded(world: &mut KanbusWorld) {
    if world.last_kanbus_issue_id.is_none() {
        record_new_kanbus_id(world);
    }
    assert!(
        world.last_kanbus_issue_id.is_some(),
        "no Kanbus issue id recorded"
    );
}

#[then("the recorded Kanbus issue id should appear in the Kanbus list output")]
fn then_recorded_id_in_list_output(world: &mut KanbusWorld) {
    let identifier = world
        .last_kanbus_issue_id
        .as_ref()
        .expect("no Kanbus issue id recorded");
    let stdout = world.stdout.as_ref().expect("command result missing");
    let display_key = format_issue_key(identifier, true);
    assert!(
        stdout.contains(&display_key),
        "recorded id not found in Kanbus list output"
    );
}

#[then("the recorded Kanbus issue id should not appear in the Kanbus list output")]
fn then_recorded_id_not_in_list_output(world: &mut KanbusWorld) {
    let identifier = world
        .last_kanbus_issue_id
        .as_ref()
        .expect("no Kanbus issue id recorded");
    let stdout = world.stdout.as_ref().expect("command result missing");
    let display_key = format_issue_key(identifier, true);
    assert!(
        !stdout.contains(&display_key),
        "recorded id unexpectedly present in Kanbus list output"
    );
}

#[when("I delete the recorded Kanbus issue")]
fn when_delete_recorded_kanbus_issue(world: &mut KanbusWorld) {
    let identifier = world
        .last_kanbus_issue_id
        .as_ref()
        .expect("no Kanbus issue id recorded");
    run_cli(world, &format!("kanbus delete {}", identifier));
}