use std::process::Command;
fn bin() -> String {
env!("CARGO_BIN_EXE_cliban").to_string()
}
fn tmp_db(tag: &str) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir()
.join(format!("cliban_issuecp_{tag}_{nanos}.db"))
.to_string_lossy()
.to_string()
}
struct Run {
stdout: String,
stderr: String,
code: i32,
}
fn run_env(db: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Run {
let mut cmd = Command::new(bin());
cmd.arg("--db")
.arg(db)
.env_remove("CLIBAN_DB")
.env_remove("XDG_DATA_HOME")
.env_remove("CLIBAN_ACTOR")
.env_remove("CLAUDE_CODE_SESSION_ID")
.env_remove("CLIBAN_OUTPUT")
.env_remove("CLIBAN_PROJECT")
.args(args);
for (k, v) in extra_env {
cmd.env(k, v);
}
let out = cmd.output().expect("run cliban");
Run {
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
code: out.status.code().unwrap_or(-1),
}
}
fn ok(db: &str, args: &[&str]) -> String {
let r = run_env(db, args, &[]);
assert_eq!(
r.code,
0,
"`cliban {}` failed: {}",
args.join(" "),
r.stderr
);
r.stdout
}
fn ok_env(db: &str, args: &[&str], extra_env: &[(&str, &str)]) -> String {
let r = run_env(db, args, extra_env);
assert_eq!(
r.code,
0,
"`cliban {}` failed: {}",
args.join(" "),
r.stderr
);
r.stdout
}
fn show_json(db: &str, key: &str) -> serde_json::Value {
serde_json::from_str(&ok(db, &["issue", "show", key, "--json"])).expect("issue json")
}
const SOURCE_DESC: &str = "## Spec\n\nthe spec body\n\n## Plan\n\n### Task 1: build\n\n\
- [x] **Step 1: done step** → CLI-9\n- [ ] **Step 2: open step**\n - [x] child bullet stays\n\n\
## Decisions so far\n\n- instance deliberation\n\n## Notes\n\ndurable lesson\n";
fn seeded(tag: &str) -> String {
let db = tmp_db(tag);
ok(&db, &["project", "add", "CLI", "Cliban"]);
ok(&db, &["milestone", "add", "v1", "--project", "CLI"]);
ok(&db, &["label", "add", "feature", "--project", "CLI"]);
ok(
&db,
&[
"issue",
"add",
"template issue",
"--project",
"CLI",
"--description",
SOURCE_DESC,
"--priority",
"high",
"--milestone",
"v1",
"--due",
"2026-01-15",
"--label",
"feature",
],
);
ok(&db, &["issue", "add", "other", "--project", "CLI"]);
ok(&db, &["issue", "edit", "CLI-1", "--blocks", "CLI-2"]);
ok(&db, &["issue", "claim", "CLI-1", "--by", "someone"]);
ok(&db, &["issue", "log", "CLI-1", "original history entry"]);
db
}
#[test]
fn cp_copies_the_shape_and_never_the_history() {
let db = seeded("shape");
let before = show_json(&db, "CLI-1");
let out = ok(&db, &["issue", "cp", "CLI-1", "--json"]);
let copy: serde_json::Value = serde_json::from_str(&out).expect("cp --json echoes the issue");
assert_eq!(copy["key"], "CLI-3");
assert_eq!(copy["title"], "template issue");
assert_eq!(copy["priority"], "high");
assert_eq!(copy["labels"], serde_json::json!(["feature"]));
assert_eq!(copy["milestone"], "v1");
assert_eq!(copy["status"], "backlog");
assert!(copy.get("archived").is_none(), "{copy}");
assert!(copy.get("description").is_none(), "{copy}");
let shown = show_json(&db, "CLI-3");
let desc = shown["description"].as_str().unwrap();
assert!(desc.contains("## Spec\n\nthe spec body\n"), "{desc:?}");
assert!(
desc.contains("- [ ] **Step 1: done step**\n"),
"checkbox reset + promotion suffix stripped: {desc:?}"
);
assert!(desc.contains("- [ ] **Step 2: open step**"));
assert!(
desc.contains(" - [x] child bullet stays"),
"indented boxes are not steps: {desc:?}"
);
assert!(desc.contains("## Notes\n\ndurable lesson\n"));
assert!(!desc.contains("Activity Log"), "{desc:?}");
assert!(!desc.contains("original history entry"));
assert!(!desc.contains("Decisions so far"));
assert!(
!desc.contains("CLI-9"),
"dangling promotion pointer: {desc:?}"
);
assert!(copy.get("relations").is_none(), "{copy}");
assert!(copy.get("claimed_by").is_none(), "copy must be unclaimed");
assert!(copy.get("due_date").is_none(), "{copy}");
assert!(copy.get("completed_at").is_none(), "{copy}");
let after = show_json(&db, "CLI-1");
assert_eq!(before["description"], after["description"]);
assert_eq!(before["status"], after["status"]);
assert_eq!(before["claimed_by"], after["claimed_by"]);
assert_eq!(before["relations"], after["relations"]);
assert_eq!(before["due_date"], after["due_date"]);
}
#[test]
fn cp_title_overrides_and_table_mode_confirms() {
let db = seeded("title");
let out = ok_env(
&db,
&["issue", "cp", "CLI-1", "--title", "second run"],
&[("CLIBAN_OUTPUT", "table")],
);
assert_eq!(out, "copied CLI-3: second run\n");
let copy = show_json(&db, "CLI-3");
assert_eq!(copy["title"], "second run");
let piped = ok(&db, &["issue", "cp", "CLI-1"]);
let v: serde_json::Value = serde_json::from_str(&piped).expect("piped cp echoes JSON");
assert_eq!(v["key"], "CLI-4");
}
#[test]
fn cp_cross_project_drops_the_milestone() {
let db = seeded("xproj");
ok(&db, &["project", "add", "OPS", "Ops"]);
let out = ok(&db, &["issue", "cp", "CLI-1", "--project", "OPS", "--json"]);
let copy: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(copy["key"], "OPS-1");
assert_eq!(
copy["milestone"],
serde_json::Value::Null,
"milestone names are project-scoped; cross-project cp drops it"
);
assert_eq!(copy["labels"], serde_json::json!(["feature"]));
assert_eq!(copy["priority"], "high");
}
#[test]
fn cp_records_provenance_on_the_copy() {
let db = seeded("audit");
ok(&db, &["issue", "cp", "CLI-1", "--json"]);
let activity = ok(&db, &["activity", "--issue", "CLI-3", "--table"]);
assert!(
activity.contains("copied from CLI-1"),
"audit entry missing: {activity:?}"
);
ok(&db, &["project", "add", "OPS", "Ops"]);
ok(&db, &["issue", "cp", "CLI-1", "--project", "OPS", "--json"]);
let activity = ok(&db, &["activity", "--issue", "OPS-1", "--table"]);
assert!(
activity.contains("copied from CLI-1")
&& activity.contains("milestone")
&& activity.contains("dropped"),
"cross-project audit entry should name the milestone drop: {activity:?}"
);
}
#[test]
fn cp_of_a_missing_issue_is_not_found() {
let db = tmp_db("missing");
ok(&db, &["project", "add", "CLI", "Cliban"]);
let r = run_env(&db, &["issue", "cp", "CLI-99", "--json"], &[]);
assert_ne!(r.code, 0);
assert!(r.stderr.contains("not found"), "{}", r.stderr);
}