use std::sync::OnceLock;
use serde_json::{Value, json};
use crate::commands::{
create, delete, edit, export, import, lint, list, mv, parse_priority, parse_selector, search,
status, view,
};
use crate::config::Config;
use crate::error::Result as TixResult;
use crate::model::{Issue, Status};
use crate::search::SearchHit;
use crate::storage::Store;
use super::protocol;
struct ToolDescriptor {
name: &'static str,
description: &'static str,
input_schema: Value,
}
type ToolOutcome = std::result::Result<Value, ()>;
fn registry() -> &'static [ToolDescriptor] {
static REGISTRY: OnceLock<Vec<ToolDescriptor>> = OnceLock::new();
REGISTRY.get_or_init(|| vec![
ToolDescriptor {
name: "create_issue",
description: "Create a new issue. Returns the created issue (its number, filename, and fields) exactly as `tix create` would write it.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string", "description": "Non-empty title (required; no TTY prompt fallback)." },
"status": { "type": "string", "description": "Column; default the first configured column." },
"priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Default medium." },
"assignee": { "type": "string", "description": "Handle (`alice`/`@alice`/`@me`) or full identity; default git author." },
"labels": { "type": "string", "description": "Comma-separated labels." },
"links": { "type": "string", "description": "Comma-separated issue numbers to link (back-references added)." },
"files": { "type": "string", "description": "Comma-separated spec file paths." },
"estimate": { "type": "string" },
"body": { "type": "string", "description": "Markdown body." }
},
"required": ["title"]
}),
},
ToolDescriptor {
name: "list_issues",
description: "List issues as the board table. Filter by column.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"status": { "type": "string", "description": "Column to filter by." }
}
}),
},
ToolDescriptor {
name: "get_issue",
description: "Return the full issue (frontmatter + body) for a selector.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "board_status",
description: "Column counts plus the total (`tix status`).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "update_issue",
description: "Merge fields into an issue (CLI `tix edit` flag path only; `$EDITOR` is never opened). At least one optional field must be provided.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"title": { "type": "string" },
"body": { "type": "string" },
"status": { "type": "string" },
"priority": { "type": "string", "enum": ["low", "medium", "high"] },
"assignee": { "type": "string" },
"labels": { "type": "string", "description": "Comma-separated." },
"links": { "type": "string", "description": "Comma-separated issue numbers replacing the link list (back-references reconciled)." },
"files": { "type": "string", "description": "Comma-separated spec file paths." },
"estimate": { "type": "string" },
"commit": { "type": "string", "description": "Record a commit on the issue; `\"clear\"` removes it." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "move_issue",
description: "Move an issue to a configured column (CLI `tix move`). A commit is recorded only when passed; never prompts.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"column": { "type": "string", "description": "A configured column." },
"commit": { "type": "string", "description": "Optional commit to record when moving to the done column." }
},
"required": ["issue", "column"]
}),
},
ToolDescriptor {
name: "delete_issue",
description: "Delete an issue file. Irreversible; requires confirm: true.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"confirm": { "type": "boolean", "description": "Must be true to delete." }
},
"required": ["issue", "confirm"]
}),
},
ToolDescriptor {
name: "search_issues",
description: "Case-insensitive substring search over the full issue markdown (frontmatter + body + comments), same corpus and matcher as `tix search`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}),
},
ToolDescriptor {
name: "comment_on_issue",
description: "Append a comment to an issue's `## Comments` section via the model writer. Whitespace-only text is a no-op.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"author": { "type": "string" },
"text": { "type": "string" }
},
"required": ["issue", "author", "text"]
}),
},
ToolDescriptor {
name: "export_issues",
description: "Mirror local issues to GitLab or Linear via the CLI tool (`glab`/`linear-cli`); idempotent.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"target": { "type": "string", "enum": ["gitlab", "linear"] },
"project": { "type": "string", "description": "Optional `--project` override." },
"dry_run": { "type": "boolean", "description": "Do not invoke the external CLI." }
},
"required": ["target"]
}),
},
ToolDescriptor {
name: "import_issues",
description: "Pull open remote issues from GitLab or Linear via the CLI tool; idempotent by remote id.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"target": { "type": "string", "enum": ["gitlab", "linear"] }
},
"required": ["target"]
}),
},
ToolDescriptor {
name: "link_issue",
description: "Symmetrically link two issues by selector (`tix link`). Both issues must exist; linking an issue to itself errors. Returns both updated issues.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"a": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"b": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["a", "b"]
}),
},
ToolDescriptor {
name: "lint_issues",
description: "Validate frontmatter values in every issue file (same checks as `tix lint`); never crashes on malformed files.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
])
}
pub fn list() -> Vec<Value> {
registry()
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})
})
.collect()
}
fn req_string(args: &Value, key: &str) -> std::result::Result<String, ()> {
let value = args.get(key).ok_or(())?;
value.as_str().map(str::to_string).ok_or(())
}
fn opt_string(args: &Value, key: &str) -> std::result::Result<Option<String>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value.as_str().map(|s| Some(s.to_string())).ok_or(())
}
fn opt_bool(args: &Value, key: &str) -> std::result::Result<Option<bool>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value.as_bool().map(Some).ok_or(())
}
fn selector(args: &Value, key: &str) -> std::result::Result<String, ()> {
let value = args.get(key).ok_or(())?;
match value {
Value::Null => Err(()),
Value::String(s) => Ok(s.clone()),
Value::Number(n) => n.as_i64().map(|i| i.to_string()).ok_or(()),
_ => Err(()),
}
}
fn success_result(text: String, structured: Value) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": structured,
"isError": false,
})
}
fn error_result(text: String) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"isError": true,
})
}
fn issue_value(issue: &Issue) -> Value {
let mut obj = serde_json::to_value(issue).expect("Issue serialization is infallible");
obj["filename"] = Value::String(issue.filename.clone());
obj["body"] = Value::String(issue.body.clone());
obj
}
fn create_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let title = req_string(args, "title")?;
let body = opt_string(args, "body")?;
let status = match opt_string(args, "status")? {
None => None,
Some(s) if s.trim().is_empty() => {
return Ok(error_result(
"invalid input: --status must not be empty".into(),
));
}
Some(s) => Some(Status::new(s)),
};
let priority = match opt_string(args, "priority")? {
None => None,
Some(p) => match parse_priority(&p) {
Ok(priority) => Some(priority),
Err(e) => return Ok(error_result(e.to_string())),
},
};
let assignee = opt_string(args, "assignee")?;
let labels = opt_string(args, "labels")?;
let links = opt_string(args, "links")?;
let files = opt_string(args, "files")?;
let estimate = opt_string(args, "estimate")?;
match create::create(
config,
store,
&title,
body.as_deref(),
status,
priority,
assignee.as_deref(),
labels.as_deref(),
links.as_deref(),
files.as_deref(),
estimate.as_deref(),
) {
Ok(issue) => Ok(success_result(
format!("Created {}", issue.filename),
issue_value(&issue),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn list_issues(store: &Store, args: &Value) -> ToolOutcome {
let status = opt_string(args, "status")?;
match list::query(store, status.as_deref()) {
Ok(issues) => {
let values: Vec<Value> = issues.iter().map(issue_value).collect();
Ok(success_result(
list_text(&issues),
json!({ "issues": values }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn get_issue(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
match view::get(store, &raw) {
Ok(issue) => Ok(success_result(
issue.to_file_contents(),
issue_value(&issue),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn board_status(config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
match status::board(config, store) {
Ok(summary) => {
let columns: Vec<Value> = summary
.columns
.iter()
.map(|(column, count)| json!({ "column": column, "count": count }))
.collect();
Ok(success_result(
status_text(config, &summary),
json!({ "columns": columns, "total": summary.total }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn update_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
let title = opt_string(args, "title")?;
let body = opt_string(args, "body")?;
let status = opt_string(args, "status")?;
let priority = opt_string(args, "priority")?;
let assignee = opt_string(args, "assignee")?;
let labels = opt_string(args, "labels")?;
let links = opt_string(args, "links")?;
let files = opt_string(args, "files")?;
let estimate = opt_string(args, "estimate")?;
let commit = opt_string(args, "commit")?;
if title.is_none()
&& body.is_none()
&& status.is_none()
&& priority.is_none()
&& assignee.is_none()
&& labels.is_none()
&& links.is_none()
&& files.is_none()
&& estimate.is_none()
&& commit.is_none()
{
return Ok(error_result(
"invalid input: no fields to update — pass at least one of title, body, status, priority, assignee, labels, links, files, estimate, commit"
.into(),
));
}
match edit::apply(
config,
store,
&raw,
title.as_deref(),
body.as_deref(),
status.as_deref(),
priority.as_deref(),
assignee.as_deref(),
labels.as_deref(),
links.as_deref(),
files.as_deref(),
estimate.as_deref(),
commit.as_deref(),
) {
Ok(issue) => Ok(success_result(
format!("Updated {}", issue.filename),
issue_value(&issue),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn move_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
let column = req_string(args, "column")?;
let commit = opt_string(args, "commit")?;
match mv::apply(config, store, &raw, &column, commit.as_deref()) {
Ok(issue) => Ok(success_result(
format!("Moved {} to {column}", issue.filename),
issue_value(&issue),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn link_issue(store: &Store, args: &Value) -> ToolOutcome {
let a = selector(args, "a")?;
let b = selector(args, "b")?;
match store.link(&a, &b) {
Ok((issue_a, issue_b)) => Ok(success_result(
format!("Linked #{} ↔ #{}", issue_a.number, issue_b.number),
json!({ "a": issue_value(&issue_a), "b": issue_value(&issue_b) }),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn delete_issue(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
if args.get("confirm").and_then(Value::as_bool) != Some(true) {
return Ok(error_result(
"delete requires confirm=true — the issue file is untouched".into(),
));
}
let filename = match resolve_filename(store, &raw) {
Ok(f) => f,
Err(e) => return Ok(error_result(e.to_string())),
};
match delete::remove(store, &raw, true) {
Ok(()) => Ok(success_result(
format!("Deleted {filename}"),
json!({ "deleted": filename }),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn search_issues(store: &Store, args: &Value) -> ToolOutcome {
let query = req_string(args, "query")?;
match search::find(store, &query) {
Ok(hits) => {
let values: Vec<Value> = hits
.iter()
.map(|hit| json!({ "issue": issue_value(&hit.issue), "matched_lines": hit.matched_lines }))
.collect();
Ok(success_result(
search_text(&hits),
json!({ "hits": values }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn comment_on_issue(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
let author = req_string(args, "author")?;
let text = req_string(args, "text")?;
let outcome = (|| -> TixResult<Issue> {
let selector = parse_selector(store, &raw)?;
let mut issue = store.get(selector)?;
issue.add_comment(&author, &text);
store.update(&issue.filename, &issue)?;
Ok(issue)
})();
match outcome {
Ok(issue) => Ok(success_result(
format!("Commented on {}", issue.filename),
issue_value(&issue),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn export_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let target = req_string(args, "target")?;
let dry_run = opt_bool(args, "dry_run")?.unwrap_or(false);
let project = opt_string(args, "project")?;
match export::run_core(config, store, Some(&target), dry_run, project.as_deref()) {
Ok(report) => Ok(success_result(report.clone(), json!({ "report": report }))),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn import_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let target = req_string(args, "target")?;
match import::run_core(config, store, Some(&target)) {
Ok(report) => Ok(success_result(report.clone(), json!({ "report": report }))),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn lint_issues(config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let findings = match lint::collect(config, store) {
Ok(f) => f,
Err(e) => return Ok(error_result(e.to_string())),
};
let count = match store.list() {
Ok(issues) => issues.len(),
Err(e) => return Ok(error_result(e.to_string())),
};
let values: Vec<Value> = findings
.iter()
.map(|f| {
json!({
"file": f.file,
"line": f.line,
"column": f.column,
"kind": f.kind,
"value": f.value,
"message": f.message,
})
})
.collect();
Ok(success_result(
lint_text(count, &findings),
json!({ "findings": values }),
))
}
fn clip(value: &str, width: usize) -> String {
value.chars().take(width).collect()
}
fn list_text(issues: &[Issue]) -> String {
let num_width = 6usize;
let title_width = issues
.iter()
.map(|i| i.title.chars().count())
.chain([5])
.max()
.unwrap()
.min(40);
let status_width = issues
.iter()
.map(|i| i.status.display().chars().count())
.chain([6])
.max()
.unwrap()
.min(16);
let priority_width = issues
.iter()
.map(|i| i.priority.to_string().chars().count())
.chain([8])
.max()
.unwrap()
.min(12);
let mut out = String::new();
out.push_str(&format!(
"{:<num_width$} {:<title_width$} {:<status_width$} {:<priority_width$}\n",
"NUM", "TITLE", "STATUS", "PRIORITY"
));
for issue in issues {
out.push_str(&format!(
"{:<num_width$} {:<title_width$} {:<status_width$} {:<priority_width$}\n",
issue.number,
clip(&issue.title, title_width),
clip(issue.status.display(), status_width),
clip(&issue.priority.to_string(), priority_width),
));
}
out
}
fn status_text(config: &Config, summary: &status::BoardSummary) -> String {
let column_width = config
.default_config()
.columns
.iter()
.map(|c| c.chars().count())
.chain([6, 5])
.max()
.unwrap()
.min(30);
let count_width = summary.total.to_string().len().max(5).max(1);
let mut out = String::new();
out.push_str(&format!(
"{:<column_width$} {:<count_width$}\n",
"COLUMN", "COUNT"
));
for (column, count) in &summary.columns {
out.push_str(&format!(
"{:<column_width$} {:<count_width$}\n",
column,
count.to_string()
));
}
out.push_str(&format!(
"{:<column_width$} {:<count_width$}\n",
"Total",
summary.total.to_string()
));
out
}
const MAX_LINE_WIDTH: usize = 200;
fn clipped(line: &str) -> String {
if line.chars().count() <= MAX_LINE_WIDTH {
return line.to_string();
}
let head: String = line.chars().take(MAX_LINE_WIDTH).collect();
format!("{head}…")
}
fn search_text(hits: &[SearchHit]) -> String {
if hits.is_empty() {
return "No issues found.\n".to_string();
}
let mut out = String::new();
for hit in hits {
out.push_str(&format!(
"#{} {} — {} ({})\n",
hit.issue.number, hit.issue.title, hit.issue.status, hit.issue.filename
));
for line in &hit.matched_lines {
out.push_str(&format!(" {}\n", clipped(line)));
}
}
out
}
fn lint_text(n_issues: usize, findings: &[lint::TixIssueFinding]) -> String {
if findings.is_empty() {
return format!("Lint OK: {n_issues} issues checked.\n");
}
let mut out = format!(
"Checked {n_issues} issues; {} finding(s).\n",
findings.len()
);
for f in findings {
out.push_str(&format!(
"{}:{}:{} {} {} {}\n",
f.file, f.line, f.column, f.kind, f.value, f.message
));
}
out
}
fn resolve_filename(store: &Store, raw: &str) -> TixResult<String> {
let selector = parse_selector(store, raw)?;
let issue = store.get(selector)?;
Ok(issue.filename)
}
pub fn call(
config: &Config,
store: &Store,
id: protocol::Id,
name: &str,
args: &Value,
) -> std::result::Result<Value, protocol::ErrorResponse> {
if !registry().iter().any(|t| t.name == name) {
return Err(protocol::method_not_found(id));
}
let arguments = if args.is_null() { &Value::Null } else { args };
let outcome: ToolOutcome = match name {
"create_issue" => create_issue(config, store, arguments),
"list_issues" => list_issues(store, arguments),
"get_issue" => get_issue(store, arguments),
"board_status" => board_status(config, store, arguments),
"update_issue" => update_issue(config, store, arguments),
"move_issue" => move_issue(config, store, arguments),
"link_issue" => link_issue(store, arguments),
"delete_issue" => delete_issue(store, arguments),
"search_issues" => search_issues(store, arguments),
"comment_on_issue" => comment_on_issue(store, arguments),
"export_issues" => export_issues(config, store, arguments),
"import_issues" => import_issues(config, store, arguments),
"lint_issues" => lint_issues(config, store, arguments),
_ => unreachable!("name validated against the registry above"),
};
outcome.map_err(|()| protocol::invalid_params(id))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use std::fs;
use tempfile::TempDir;
fn make_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn write_issue(store: &Store, number: u32, title: &str, status: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
);
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
contents,
)
.unwrap();
}
fn invoke(config: &Config, store: &Store, name: &str, args: Value) -> Value {
call(config, store, protocol::Id::Number(1), name, &args)
.expect("protocol-level calls in unit tests must not fail")
}
#[test]
fn registry_has_13_tools_with_valid_schemas() {
let tools = list();
assert_eq!(tools.len(), 13);
for tool in &tools {
let schema = &tool["inputSchema"];
assert_eq!(schema["type"], "object");
assert_eq!(schema["additionalProperties"], false);
assert!(tool["description"].as_str().is_some_and(|d| !d.is_empty()));
assert!(tool["name"].as_str().is_some());
}
}
#[test]
fn registry_names_match_the_contract_parity_table() {
let tools = list();
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
assert_eq!(names.len(), 13);
for expected in [
"create_issue",
"list_issues",
"get_issue",
"board_status",
"update_issue",
"move_issue",
"link_issue",
"delete_issue",
"search_issues",
"comment_on_issue",
"export_issues",
"import_issues",
"lint_issues",
] {
assert!(names.contains(&expected), "missing tool {expected}");
}
}
#[test]
fn arg_helpers_reject_wrong_types() {
let args = json!({ "title": 42, "dry_run": "yes", "issue": true });
assert!(req_string(&args, "title").is_err());
assert!(opt_string(&args, "title").is_err());
assert!(opt_bool(&args, "dry_run").is_err());
assert!(selector(&args, "issue").is_err());
assert_eq!(selector(&json!({ "issue": 123 }), "issue").unwrap(), "123");
assert_eq!(
selector(&json!({ "issue": "n 1" }), "issue").unwrap(),
"n 1"
);
}
#[test]
fn null_optionals_are_treated_as_absent() {
assert_eq!(opt_string(&json!({ "x": null }), "x").unwrap(), None);
assert_eq!(opt_bool(&json!({ "x": null }), "x").unwrap(), None);
assert!(req_string(&json!({ "x": null }), "x").is_err());
}
#[test]
fn call_type_mismatch_is_invalid_params() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(1),
"create_issue",
&json!({ "title": 5 }),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::INVALID_PARAMS);
assert_eq!(err.id, protocol::Id::Number(1));
}
#[test]
fn call_unknown_tool_is_method_not_found() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(2),
"bogus",
&json!({}),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::METHOD_NOT_FOUND);
}
#[test]
fn create_issue_writes_file_and_returns_structured_result() {
let (_tmp, config, store) = make_repo();
let result = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Build the tool registry", "labels": "mcp, core", "priority": "high" }),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["title"], "Build the tool registry");
assert_eq!(structured["labels"], json!(["mcp", "core"]));
assert_eq!(structured["priority"], "high");
let filename = structured["filename"].as_str().unwrap();
assert!(store.issues_dir.join(filename).exists());
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.starts_with("Created ")
);
}
#[test]
fn delete_issue_requires_confirm_and_files_survive_refusal() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let refused = invoke(&config, &store, "delete_issue", json!({ "issue": "1" }));
assert_eq!(refused["isError"], true);
assert!(store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
let done = invoke(
&config,
&store,
"delete_issue",
json!({ "issue": 1, "confirm": true }),
);
assert_eq!(done["isError"], false);
assert_eq!(
done["structuredContent"]["deleted"],
"alice/2026-01-01-alpha.md"
);
assert!(!store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
}
#[test]
fn update_issue_with_no_fields_is_a_tool_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "update_issue", json!({ "issue": "1" }));
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("no fields to update")
);
}
#[test]
fn create_issue_accepts_links_and_files_params() {
let (_tmp, config, store) = make_repo();
let a = invoke(
&config,
&store,
"create_issue",
json!({ "title": "A", "assignee": "" }),
);
let b = invoke(
&config,
&store,
"create_issue",
json!({ "title": "B", "assignee": "" }),
);
let num_a = a["structuredContent"]["number"].as_u64().unwrap();
let num_b = b["structuredContent"]["number"].as_u64().unwrap();
let linked = invoke(
&config,
&store,
"create_issue",
json!({
"title": "C",
"assignee": "",
"links": format!("{},{}", num_a, num_b),
"files": "openspec/changes/x/proposal.md,openspec/changes/x/specs/y/spec.md",
}),
);
assert_eq!(linked["isError"], false);
let structured = &linked["structuredContent"];
assert_eq!(
structured["links"],
json!([num_a.to_string(), num_b.to_string()])
);
assert_eq!(
structured["files"],
json!([
"openspec/changes/x/proposal.md",
"openspec/changes/x/specs/y/spec.md"
])
);
let reread_a = invoke(&config, &store, "get_issue", json!({ "issue": num_a }));
assert_eq!(
reread_a["structuredContent"]["links"],
json!([structured["number"].as_u64().unwrap().to_string()])
);
}
#[test]
fn update_issue_sets_links_and_files_params() {
let (_tmp, config, store) = make_repo();
let a = invoke(
&config,
&store,
"create_issue",
json!({ "title": "A", "assignee": "" }),
);
let b = invoke(
&config,
&store,
"create_issue",
json!({ "title": "B", "assignee": "" }),
);
let num_a = a["structuredContent"]["number"].as_u64().unwrap();
let num_b = b["structuredContent"]["number"].as_u64().unwrap();
let updated = invoke(
&config,
&store,
"update_issue",
json!({
"issue": num_a,
"links": num_b.to_string(),
"files": "specs/m/spec.md",
}),
);
assert_eq!(updated["isError"], false);
let structured = &updated["structuredContent"];
assert_eq!(structured["links"], json!([num_b.to_string()]));
assert_eq!(structured["files"], json!(["specs/m/spec.md"]));
}
#[test]
fn link_issue_links_two_issues_symmetrically() {
let (_tmp, config, store) = make_repo();
let a = invoke(
&config,
&store,
"create_issue",
json!({ "title": "A", "assignee": "" }),
);
let b = invoke(
&config,
&store,
"create_issue",
json!({ "title": "B", "assignee": "" }),
);
let num_a = a["structuredContent"]["number"].as_u64().unwrap();
let num_b = b["structuredContent"]["number"].as_u64().unwrap();
let result = invoke(
&config,
&store,
"link_issue",
json!({ "a": num_a, "b": num_b }),
);
assert_eq!(result["isError"], false);
assert_eq!(
result["structuredContent"]["a"]["links"],
json!([num_b.to_string()])
);
assert_eq!(
result["structuredContent"]["b"]["links"],
json!([num_a.to_string()])
);
}
#[test]
fn move_issue_unknown_column_mirrors_cli_error_text() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "nope" }),
);
assert_eq!(result["isError"], true);
assert_eq!(
result["content"][0]["text"],
"invalid input: unknown column 'nope' — valid columns: todo, in progress, in review, done"
);
}
#[test]
fn lint_clean_store_reports_ok() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "lint_issues", json!({}));
assert_eq!(result["isError"], false);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.starts_with("Lint OK: 1 issues checked.")
);
}
#[test]
fn export_without_gitlab_config_is_a_structured_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"export_issues",
json!({ "target": "gitlab", "dry_run": true }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("no `[export.gitlab]` section"), "got: {text}");
}
}