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::fs;
use std::path::PathBuf;
use std::process::Command;

use cucumber::{given, then, when};
use regex::Regex;
use serde_json::Value;
use std::env;
use tempfile::TempDir;

use kanbus::cli::run_from_args_with_output;
use kanbus::file_io::load_project_directory;
use kanbus::issue_creation::{create_issue, IssueCreationRequest};

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());
        }
        Err(error) => {
            world.exit_code = Some(1);
            world.stdout = Some(String::new());
            world.stderr = Some(error.to_string());
        }
    }
}

#[given("issue creation status validation fails")]
fn given_issue_creation_status_validation_fails(world: &mut KanbusWorld) {
    if world.original_invalid_status_env.is_none() {
        world.original_invalid_status_env = Some(std::env::var("KANBUS_TEST_INVALID_STATUS").ok());
    }
    std::env::set_var("KANBUS_TEST_INVALID_STATUS", "1");
}

#[when("I create an issue directly with title \"Implement OAuth2 flow\"")]
fn when_create_issue_directly(world: &mut KanbusWorld) {
    let root = world
        .working_directory
        .as_ref()
        .expect("working directory not set");
    let request = IssueCreationRequest {
        root: root.clone(),
        title: "Implement OAuth2 flow".to_string(),
        issue_type: None,
        priority: None,
        assignee: None,
        parent: None,
        labels: Vec::new(),
        description: None,
        local: false,
        validate: true,
    };
    match create_issue(&request) {
        Ok(_) => {
            world.exit_code = Some(0);
            world.stdout = Some(String::new());
            world.stderr = Some(String::new());
        }
        Err(error) => {
            world.exit_code = Some(1);
            world.stdout = Some(String::new());
            world.stderr = Some(error.to_string());
        }
    }
}

fn load_project_dir(world: &KanbusWorld) -> PathBuf {
    let cwd = world.working_directory.as_ref().expect("cwd");
    load_project_directory(cwd).expect("project dir")
}

fn capture_issue_identifier(world: &mut KanbusWorld) -> String {
    let stdout = world.stdout.as_ref().expect("stdout");
    let ansi_regex = Regex::new(r"\x1b\[[0-9;]*m").expect("regex");
    let clean_stdout = ansi_regex.replace_all(stdout, "");
    let full_regex = Regex::new(
        r"([A-Za-z0-9]+-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})",
    )
    .expect("regex");
    if let Some(capture) = full_regex
        .captures(&clean_stdout)
        .and_then(|matches| matches.get(1))
        .map(|match_value| match_value.as_str().to_string())
    {
        world.stdout = Some(stdout.to_string());
        return capture;
    }

    let labeled_regex = Regex::new(r"(?i)\bID:\s*([A-Za-z0-9.-]+)").expect("regex");
    let abbreviated = labeled_regex
        .captures(&clean_stdout)
        .and_then(|matches| matches.get(1))
        .map(|match_value| match_value.as_str().to_string())
        .unwrap_or_else(|| {
            let fallback_regex = Regex::new(r"\b([A-Za-z0-9]{6}(?:\.[0-9]+)?)\b").expect("regex");
            fallback_regex
                .captures(&clean_stdout)
                .and_then(|matches| matches.get(1))
                .map(|match_value| match_value.as_str().to_string())
                .expect("issue id not found")
        });
    let (abbrev_base, abbrev_suffix) = parse_abbreviation(&abbreviated);
    let project_dir = load_project_dir(world);
    let issues_dir = project_dir.join("issues");
    let entries = fs::read_dir(issues_dir).expect("read issues dir");
    for entry in entries {
        let path = entry.expect("issue entry").path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
            continue;
        }
        let identifier = path
            .file_stem()
            .and_then(|stem| stem.to_str())
            .unwrap_or_default()
            .to_string();
        if matches_abbreviation(&identifier, &abbrev_base, abbrev_suffix.as_deref()) {
            world.stdout = Some(stdout.to_string());
            return identifier;
        }
    }
    panic!("issue id not found");
}

fn parse_abbreviation(value: &str) -> (String, Option<String>) {
    let remainder = if let Some((_, rest)) = value.split_once('-') {
        rest
    } else {
        value
    };
    if let Some((base, suffix)) = remainder.split_once('.') {
        (base.to_lowercase(), Some(suffix.to_lowercase()))
    } else {
        (remainder.to_lowercase(), None)
    }
}

fn matches_abbreviation(identifier: &str, base: &str, suffix: Option<&str>) -> bool {
    let remainder = if let Some((_, rest)) = identifier.split_once('-') {
        rest
    } else {
        identifier
    };
    let (id_base, id_suffix) = if let Some((head, tail)) = remainder.split_once('.') {
        (head, Some(tail))
    } else {
        (remainder, None)
    };
    let normalized = id_base.replace('-', "").to_lowercase();
    if !normalized.starts_with(base) {
        return false;
    }
    match (suffix, id_suffix) {
        (None, _) => true,
        (Some(expected), Some(actual)) => actual.to_lowercase() == expected.to_lowercase(),
        _ => false,
    }
}

fn load_issue_json(project_dir: &PathBuf, identifier: &str) -> Value {
    let issue_path = project_dir
        .join("issues")
        .join(format!("{identifier}.json"));
    let contents = fs::read_to_string(issue_path).expect("read issue");
    serde_json::from_str(&contents).expect("parse issue")
}

#[given("a Kanbus project with default configuration")]
fn given_kanbus_project(world: &mut KanbusWorld) {
    env::set_var("KANBUS_NO_DAEMON", "1");
    let temp_dir = TempDir::new().expect("tempdir");
    let repo_path = temp_dir.path().join("repo");
    fs::create_dir_all(&repo_path).expect("create repo dir");
    Command::new("git")
        .args(["init"])
        .current_dir(&repo_path)
        .output()
        .expect("git init failed");
    world.working_directory = Some(repo_path);
    world.temp_dir = Some(temp_dir);
    run_cli(world, "kanbus init");
    assert_eq!(world.exit_code, Some(0));
}

#[then("the command should succeed")]
fn then_command_succeeds(world: &mut KanbusWorld) {
    assert_eq!(
        world.exit_code,
        Some(0),
        "stderr: {:?}",
        world.stderr.as_deref().unwrap_or("")
    );
}

#[then("stdout should contain a valid issue ID")]
fn then_stdout_contains_issue_id(world: &mut KanbusWorld) {
    let _ = capture_issue_identifier(world);
}

#[then("an issue file should be created in the issues directory")]
fn then_issue_file_created(world: &mut KanbusWorld) {
    let project_dir = load_project_dir(world);
    let issues_dir = project_dir.join("issues");
    let count = fs::read_dir(&issues_dir)
        .expect("read issues dir")
        .filter(|entry| {
            let Ok(item) = entry else {
                return false;
            };
            let path = item.path();
            path.extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| ext == "json")
                .unwrap_or(false)
        })
        .count();
    assert_eq!(count, 1);
}

#[then(expr = "the issues directory should contain {int} issue file")]
fn then_issues_directory_contains_count(world: &mut KanbusWorld, count: i32) {
    let project_dir = load_project_dir(world);
    let issues_dir = project_dir.join("issues");
    let actual = fs::read_dir(&issues_dir)
        .expect("read issues dir")
        .filter(|entry| {
            let Ok(item) = entry else {
                return false;
            };
            let path = item.path();
            path.extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| ext == "json")
                .unwrap_or(false)
        })
        .count();
    assert_eq!(actual as i32, count);
}

#[then("the created issue should have title \"Implement OAuth2 flow\"")]
fn then_created_issue_title(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["title"], "Implement OAuth2 flow");
}

#[then("the created issue should have type \"task\"")]
fn then_created_issue_type_task(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["type"], "task");
}

#[then("the created issue should have status \"open\"")]
fn then_created_issue_status(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["status"], "open");
}

#[then("the created issue should have priority 2")]
fn then_created_issue_priority(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["priority"], 2);
}

#[then("the created issue should have an empty labels list")]
fn then_created_issue_labels_empty(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["labels"].as_array().map(Vec::len), Some(0));
}

#[then("the created issue should have an empty dependencies list")]
fn then_created_issue_dependencies_empty(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["dependencies"].as_array().map(Vec::len), Some(0));
}

#[then("the created issue should have a created_at timestamp")]
fn then_created_issue_created_at(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert!(payload.get("created_at").is_some());
}

#[then("the created issue should have an updated_at timestamp")]
fn then_created_issue_updated_at(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert!(payload.get("updated_at").is_some());
}

#[then("the created issue should have type \"bug\"")]
fn then_created_issue_type_bug(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["type"], "bug");
}

#[then("the created issue should have priority 1")]
fn then_created_issue_priority_one(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["priority"], 1);
}

#[then("the created issue should have assignee \"dev@example.com\"")]
fn then_created_issue_assignee(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["assignee"], "dev@example.com");
}

#[then(expr = "the created issue should have parent {string}")]
fn then_created_issue_parent(world: &mut KanbusWorld, parent: String) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["parent"], parent);
}

#[then("the created issue should have labels \"auth, urgent\"")]
fn then_created_issue_labels(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    let labels = payload["labels"]
        .as_array()
        .expect("labels array")
        .iter()
        .filter_map(|value| value.as_str())
        .collect::<Vec<_>>();
    assert_eq!(labels, vec!["auth", "urgent"]);
}

#[then("the created issue should have description \"Bug in login\"")]
fn then_created_issue_description(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert_eq!(payload["description"], "Bug in login");
}

#[then("the created issue should have no parent")]
fn then_created_issue_no_parent(world: &mut KanbusWorld) {
    let identifier = capture_issue_identifier(world);
    let project_dir = load_project_dir(world);
    let payload = load_issue_json(&project_dir, &identifier);
    assert!(payload["parent"].is_null());
}