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

use chrono::{TimeZone, Utc};
use cucumber::{given, then, when};

use kanbus::cli::run_from_args_with_output;
use kanbus::config_loader::load_project_configuration;
use kanbus::daemon_client::{has_test_daemon_response, set_test_daemon_response};
use kanbus::daemon_protocol::{RequestEnvelope, PROTOCOL_VERSION};
use kanbus::daemon_server::handle_request_for_testing;
use kanbus::file_io::{get_configuration_path, load_project_directory};
use kanbus::issue_listing::list_issues;
use kanbus::models::IssueData;
use tempfile::TempDir;

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

fn run_cli(world: &mut KanbusWorld, command: &str) {
    if command.starts_with("kanbus list")
        && kanbus::daemon_client::is_daemon_enabled()
        && !has_test_daemon_response()
    {
        let root = world
            .working_directory
            .as_ref()
            .expect("working directory not set");
        let request = RequestEnvelope {
            protocol_version: PROTOCOL_VERSION.to_string(),
            request_id: "req-list".to_string(),
            action: "index.list".to_string(),
            payload: BTreeMap::new(),
        };
        let response = handle_request_for_testing(root.as_path(), request);
        set_test_daemon_response(Some(kanbus::daemon_client::TestDaemonResponse::Envelope(
            response,
        )));
    }
    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());
        }
    }
}

fn initialize_project(world: &mut KanbusWorld) {
    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));
}

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

fn write_issue_file(project_dir: &PathBuf, issue: &IssueData) {
    let issue_path = project_dir
        .join("issues")
        .join(format!("{}.json", issue.identifier));
    let contents = serde_json::to_string_pretty(issue).expect("serialize issue");
    fs::write(issue_path, contents).expect("write issue");
}

fn build_issue(identifier: &str) -> IssueData {
    let timestamp = Utc.with_ymd_and_hms(2026, 2, 11, 0, 0, 0).unwrap();
    IssueData {
        identifier: identifier.to_string(),
        title: "Title".to_string(),
        description: "".to_string(),
        issue_type: "task".to_string(),
        status: "open".to_string(),
        priority: 2,
        assignee: None,
        creator: None,
        parent: None,
        labels: Vec::new(),
        dependencies: Vec::new(),
        comments: Vec::new(),
        created_at: timestamp,
        updated_at: timestamp,
        closed_at: None,
        custom: std::collections::BTreeMap::new(),
    }
}

#[given(expr = "issues {string} and {string} exist")]
fn given_issues_exist(world: &mut KanbusWorld, first: String, second: String) {
    let project_dir = load_project_dir(world);
    let issue_first = build_issue(&first);
    let issue_second = build_issue(&second);
    write_issue_file(&project_dir, &issue_first);
    write_issue_file(&project_dir, &issue_second);
}

#[given(expr = "issues {string} exist")]
fn given_single_issue_exists(world: &mut KanbusWorld, identifier: String) {
    let project_dir = load_project_dir(world);
    let issue = build_issue(&identifier);
    write_issue_file(&project_dir, &issue);
}

#[given("a Kanbus repository with an unreadable project directory")]
fn given_repo_unreadable_project_dir(world: &mut KanbusWorld) {
    initialize_project(world);
    let project_dir = load_project_dir(world);
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut permissions = fs::metadata(&project_dir)
            .expect("project metadata")
            .permissions();
        let original = permissions.mode();
        permissions.set_mode(0o000);
        fs::set_permissions(&project_dir, permissions).expect("set permissions");
        world.unreadable_path = Some(project_dir);
        world.unreadable_mode = Some(original);
    }
}

#[given(expr = "issue {string} has status {string}")]
fn given_issue_has_status(world: &mut KanbusWorld, identifier: String, status: String) {
    let project_dir = load_project_dir(world);
    let mut issue = build_issue(&identifier);
    issue.status = status;
    write_issue_file(&project_dir, &issue);
}

#[given(expr = "issue {string} has type {string}")]
fn given_issue_has_type(world: &mut KanbusWorld, identifier: String, issue_type: String) {
    let project_dir = load_project_dir(world);
    let mut issue = build_issue(&identifier);
    issue.issue_type = issue_type;
    write_issue_file(&project_dir, &issue);
}

#[given(expr = "issue {string} has assignee {string}")]
fn given_issue_has_assignee(world: &mut KanbusWorld, identifier: String, assignee: String) {
    let project_dir = load_project_dir(world);
    let mut issue = build_issue(&identifier);
    issue.assignee = Some(assignee);
    write_issue_file(&project_dir, &issue);
}

#[given(expr = "issue {string} has labels {string}")]
fn given_issue_has_labels(world: &mut KanbusWorld, identifier: String, label_text: String) {
    let project_dir = load_project_dir(world);
    let mut issue = build_issue(&identifier);
    issue.labels = label_text
        .split(',')
        .map(|label| label.trim())
        .filter(|label| !label.is_empty())
        .map(str::to_string)
        .collect();
    write_issue_file(&project_dir, &issue);
}

#[given(expr = "issue {string} has priority {int}")]
fn given_issue_has_priority(world: &mut KanbusWorld, identifier: String, priority: String) {
    let project_dir = load_project_dir(world);
    let mut issue = build_issue(&identifier);
    let parsed = priority.parse::<i32>().expect("priority int");
    issue.priority = parsed;
    write_issue_file(&project_dir, &issue);
}

#[given(expr = "issue {string} has parent {string}")]
fn given_issue_has_parent(world: &mut KanbusWorld, identifier: String, parent: String) {
    let project_dir = load_project_dir(world);
    let issue_path = project_dir
        .join("issues")
        .join(format!("{}.json", identifier));
    let contents = fs::read_to_string(&issue_path).expect("read issue");
    let mut issue: IssueData = serde_json::from_str(&contents).expect("parse issue");
    issue.parent = Some(parent);
    write_issue_file(&project_dir, &issue);
}

#[given(
    expr = "an issue {string} exists with status {string}, priority {int}, type {string}, and assignee {string}"
)]
fn given_issue_with_full_metadata(
    world: &mut KanbusWorld,
    identifier: String,
    status: String,
    priority: i32,
    issue_type: String,
    assignee: String,
) {
    let project_dir = load_project_dir(world);
    let issue_path = project_dir
        .join("issues")
        .join(format!("{}.json", identifier));
    let issue = match fs::read_to_string(&issue_path) {
        Ok(contents) => serde_json::from_str(&contents).expect("parse issue"),
        Err(_) => build_issue(&identifier),
    };
    let mut issue = issue;
    issue.status = status;
    issue.priority = priority;
    issue.issue_type = issue_type;
    issue.assignee = Some(assignee);
    write_issue_file(&project_dir, &issue);
}

#[when("I list issues directly after configuration path lookup fails")]
fn when_list_issues_directly_after_configuration_failure(world: &mut KanbusWorld) {
    let root = world.working_directory.as_ref().expect("working directory");
    if let Err(error) = list_issues(root, None, None, None, None, None, None, &[], true, false) {
        world.exit_code = Some(1);
        world.stdout = Some(String::new());
        world.stderr = Some(error.to_string());
        return;
    }
    match get_configuration_path(root) {
        Ok(path) => match load_project_configuration(&path) {
            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());
            }
        },
        Err(error) => {
            world.exit_code = Some(1);
            world.stdout = Some(String::new());
            world.stderr = Some(error.to_string());
        }
    }
}

#[given(expr = "issue {string} has title {string}")]
fn given_issue_has_title(world: &mut KanbusWorld, identifier: String, title: String) {
    let project_dir = load_project_dir(world);
    let mut issue = build_issue(&identifier);
    issue.title = title;
    write_issue_file(&project_dir, &issue);
}

#[given(expr = "issue {string} has description {string}")]
fn given_issue_has_description(world: &mut KanbusWorld, identifier: String, description: String) {
    let project_dir = load_project_dir(world);
    let mut issue = build_issue(&identifier);
    issue.description = description;
    write_issue_file(&project_dir, &issue);
}

#[then(expr = "stdout should contain the line {string}")]
fn then_stdout_contains_line(world: &mut KanbusWorld, expected: String) {
    let stdout = world.stdout.as_ref().expect("stdout");
    let found = stdout.lines().any(|line| line == expected);
    assert!(found, "expected line not found in stdout");
}

#[given("the daemon list request will fail")]
fn given_daemon_list_fails(world: &mut KanbusWorld) {
    world.daemon_list_error = true;
}

#[given("local listing will fail")]
fn given_local_listing_fails(world: &mut KanbusWorld) {
    if world.original_local_listing_env.is_none() {
        world.original_local_listing_env =
            Some(std::env::var("KANBUS_TEST_LOCAL_LISTING_ERROR").ok());
    }
    std::env::set_var("KANBUS_TEST_LOCAL_LISTING_ERROR", "1");
    world.local_listing_error = true;
}

#[when("shared issues are listed without local issues")]
fn when_shared_only_listed(world: &mut KanbusWorld) {
    let project_dir = load_project_dir(world);
    let issues_dir = project_dir.join("issues");
    let mut identifiers = Vec::new();
    for entry in fs::read_dir(&issues_dir).expect("read issues") {
        let entry = entry.expect("entry");
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
            continue;
        }
        let contents = fs::read_to_string(&path).expect("read issue");
        let issue: IssueData = serde_json::from_str(&contents).expect("parse issue");
        identifiers.push(issue.identifier);
    }
    world.shared_only_results = Some(identifiers);
}

#[then(expr = "the shared-only list should contain {string}")]
fn then_shared_only_contains(world: &mut KanbusWorld, identifier: String) {
    let list = world.shared_only_results.as_ref().expect("shared list");
    assert!(list.iter().any(|item| item == &identifier));
}

#[then(expr = "the shared-only list should not contain {string}")]
fn then_shared_only_not_contains(world: &mut KanbusWorld, identifier: String) {
    let list = world.shared_only_results.as_ref().expect("shared list");
    assert!(!list.iter().any(|item| item == &identifier));
}