use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use assert_cmd::Command as CliCommand;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::{Value, json};
use tempfile::TempDir;
use tix::app::{App, Mode};
use tix::config::Config;
use tix::model::{History, Issue, Priority, Status};
use tix::search::{Corpus, StructuredFilter};
use tix::views::divergence::{ActiveViewMarker, ViewDivergence, ViewSurface};
use tix::views::plan::{ViewPlan, ViewVisibility};
use tix::views::portable::PortableDocument;
use tix::views::registry::{
SavedView, ScopeResolution, ViewDiagnosticKind, ViewId, ViewRegistry, ViewScope,
};
use tix::views::service::{DeleteConfirmed, ViewDefinition, ViewMutationKind, ViewService};
const RAW: &str = "issues_dir = \"docs/issues\"\n";
fn issue(number: u32, title: &str, status: &str, label: Option<&str>, created: &str) -> Issue {
Issue {
projects: None,
number,
id: None,
title: title.to_string(),
status: Status::new(status),
labels: label
.map(|label| vec![label.to_string()])
.unwrap_or_default(),
type_: None,
priority: Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: created.into(),
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
history: History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: BTreeMap::new(),
entered_at: None,
source: None,
archived: false,
}
}
fn corpus(issues: Vec<Issue>) -> Corpus {
Corpus::from_issues(issues)
}
fn project(raw: &str) -> (TempDir, Config) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("tix.toml");
fs::write(&path, raw).unwrap();
let config = Config::load(dir.path()).unwrap();
(dir, config)
}
fn projects(ids: &[&str]) -> BTreeSet<String> {
ids.iter().map(|id| (*id).to_string()).collect()
}
fn vid(raw: &str) -> ViewId {
ViewId::new(raw).unwrap()
}
fn definition(name: &str, plan: ViewPlan) -> ViewDefinition {
ViewDefinition::new(name, ViewScope::repository(), plan)
}
fn snapshot_plan() -> ViewPlan {
ViewPlan::from_parts(
StructuredFilter {
status: Some("todo".into()),
..StructuredFilter::default()
},
"",
)
.with_sort("due")
.with_group("cycle")
.with_visibility(ViewVisibility::new(vec!["done".into()], Some(false)))
}
fn todo_plan() -> ViewPlan {
ViewPlan::from_parts(
StructuredFilter {
status: Some("todo".into()),
..StructuredFilter::default()
},
"",
)
}
fn board_and_resolution(
config: &Config,
) -> (
tix::config::BoardConfiguration,
tix::workflow::CategoryResolution,
) {
(
config.default_config().clone(),
tix::workflow::active_resolution(config).1,
)
}
#[test]
fn fr001_c001_create_show_round_trips_the_normalized_typed_plan() {
let (dir, mut config) = project(RAW);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let plan = snapshot_plan();
let id = {
let mut service = ViewService::new(&mut config, &known, &path);
let outcome = service.create(definition("Active", plan.clone())).unwrap();
assert!(!outcome.is_noop());
assert_eq!(outcome.audit().unwrap().kind(), ViewMutationKind::Create);
let id = outcome.into_value();
assert!(id.as_str().starts_with("v-"), "generated id: {id}");
id
};
let reloaded = Config::load(dir.path()).unwrap();
let stored = reloaded.views.get(&id).expect("the view is persisted");
assert_eq!(stored.name, "Active");
assert_eq!(
stored.plan,
plan.to_toml(),
"the persisted plan is the typed plan"
);
let service = ViewService::new(&mut config, &known, &path);
let detail = service.show(id.as_str()).unwrap();
assert_eq!(
detail.plan(),
&plan,
"show re-parses the same normalized plan"
);
assert!(detail.diagnostics().is_empty());
assert!(!dir.path().join("docs/issues").exists());
}
#[test]
fn c002_fr002_rename_keeps_identity_and_delete_never_reuses_an_id() {
let raw = "\
issues_dir = \"docs/issues\"
[views.v-active]
id = \"v-active\"
name = \"Active\"
colour = \"red\"
[views.v-active.scope]
kind = \"repository\"
[views.v-active.plan]
sort = \"due\"
[views.v-active.plan.filter]
status = \"todo\"
";
let (dir, mut config) = project(raw);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let plan = ViewPlan::from_parts(
StructuredFilter {
status: Some("todo".into()),
..StructuredFilter::default()
},
"sort:due",
);
let duplicated = {
let mut service = ViewService::new(&mut config, &known, &path);
let outcome = service
.update("v-active", definition("Renamed", plan.clone()))
.unwrap();
assert_eq!(outcome.audit().unwrap().kind(), ViewMutationKind::Update);
assert_eq!(outcome.value().as_str(), "v-active", "the id is stable");
let detail = service.show("v-active").unwrap();
assert_eq!(detail.view().name, "Renamed");
assert_eq!(detail.plan(), &plan);
assert_eq!(
detail.view().extensions.get("colour"),
Some(&toml::Value::String("red".into())),
"unknown metadata survives the rename"
);
service.duplicate("v-active", None).unwrap().into_value()
};
assert_ne!(
duplicated.as_str(),
"v-active",
"a duplicate gets a new stable id"
);
let reloaded = Config::load(dir.path()).unwrap();
assert_eq!(reloaded.views.len(), 2, "two views after the duplicate");
let copy = reloaded.views.get(&duplicated).unwrap();
assert_eq!(copy.name, "Renamed");
assert_eq!(copy.plan, plan.to_toml());
assert!(copy.extensions.contains_key("colour"));
let old_id = vid("v-active");
let fresh = {
let mut service = ViewService::new(&mut config, &known, &path);
let removed = service
.delete("v-active", DeleteConfirmed::confirmed())
.unwrap()
.into_value();
assert_eq!(removed.view().id, old_id);
service
.create(definition("Active", plan.clone()))
.unwrap()
.into_value()
};
assert_ne!(fresh, old_id, "a deleted id is never handed out again");
let reloaded = Config::load(dir.path()).unwrap();
assert!(
reloaded.views.get(&old_id).is_none(),
"the deleted view is gone"
);
assert_eq!(
reloaded.views.len(),
2,
"the duplicate and the fresh view remain"
);
}
#[test]
fn fr003_c003_scope_precedence_is_deterministic_and_ties_are_explicit_conflicts() {
let plan = ViewPlan::from_query("");
let mut registry = ViewRegistry::new();
registry
.insert(
SavedView::new(vid("v-repo"), "Active", ViewScope::repository())
.unwrap()
.with_plan(plan.to_toml()),
)
.unwrap();
registry
.insert(
SavedView::new(vid("v-proj"), "Active", ViewScope::project("p-x"))
.unwrap()
.with_plan(plan.to_toml()),
)
.unwrap();
let known = projects(&["p-x"]);
assert_eq!(
registry.select("Active", &known).unwrap().id.as_str(),
"v-proj",
"project scope outranks repository"
);
assert_eq!(
registry.select("v-repo", &known).unwrap().id.as_str(),
"v-repo",
"a stable id selects exactly its view"
);
registry
.insert(
SavedView::new(
vid("v-share"),
"Active",
ViewScope::user_share(Some("alice".into()), vec![]),
)
.unwrap()
.with_plan(plan.to_toml()),
)
.unwrap();
assert_eq!(
registry.select("Active", &known).unwrap().id.as_str(),
"v-share",
"user/share metadata outranks project — but grants nothing"
);
let mut tied = ViewRegistry::new();
for id in ["v-p1", "v-p2"] {
tied.insert(
SavedView::new(vid(id), "Twin", ViewScope::project("p-x"))
.unwrap()
.with_plan(plan.to_toml()),
)
.unwrap();
}
let refusal = tied.select("Twin", &known).unwrap_err();
assert_eq!(refusal.kind(), ViewDiagnosticKind::ScopeConflict);
let message = refusal.message();
assert!(
message.contains("v-p1") && message.contains("v-p2"),
"{message}"
);
let unknown =
SavedView::new(vid("v-missing"), "Platform", ViewScope::project("p-gone")).unwrap();
assert_eq!(unknown.scope.resolution(&known), ScopeResolution::Missing);
let mut missing = ViewRegistry::new();
missing.insert(unknown).unwrap();
assert_eq!(
missing.select("v-missing", &known).unwrap_err().kind(),
ViewDiagnosticKind::ScopeConflict
);
assert!(missing.check(&known).is_err(), "the write path refuses it");
}
#[test]
fn c008_fr002_a_refused_mutation_writes_nothing_and_an_accepted_one_is_audited() {
let (dir, mut config) = project(RAW);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let before = fs::read(&path).unwrap();
{
let mut service = ViewService::new(&mut config, &known, &path);
assert!(
service
.create(definition("Bad", ViewPlan::from_query("sort:nope")))
.is_err()
);
assert_eq!(
fs::read(&path).unwrap(),
before,
"a refusal writes no config"
);
assert!(service.list().is_empty());
}
{
let mut service = ViewService::new(&mut config, &known, &path);
assert!(service.create(definition(" ", snapshot_plan())).is_err());
assert_eq!(fs::read(&path).unwrap(), before);
}
{
let mut service = ViewService::new(&mut config, &known, &path);
let missing =
ViewDefinition::new("Platform", ViewScope::project("p-gone"), snapshot_plan());
let error = service.create(missing).unwrap_err();
assert!(format!("{error:?}").contains("Refused"), "{error:?}");
assert_eq!(fs::read(&path).unwrap(), before);
}
{
let mut service = ViewService::new(&mut config, &known, &path);
let outcome = service
.create(definition("Active", snapshot_plan()))
.unwrap();
let event = outcome.audit().expect("an accepted create is audited");
assert_eq!(event.kind(), ViewMutationKind::Create);
assert_eq!(event.view_name(), "Active");
let id = outcome.into_value();
assert_eq!(service.list().len(), 1);
assert!(service.show(id.as_str()).is_ok());
}
let accepted_bytes = fs::read(&path).unwrap();
assert_ne!(accepted_bytes, before, "the accepted create wrote the view");
assert!(
String::from_utf8(accepted_bytes.clone())
.unwrap()
.contains("[views."),
"the view landed in tix.toml"
);
{
let mut service = ViewService::new(&mut config, &known, &path);
let bad = definition("Bad", ViewPlan::from_query("sort:nope"));
assert!(service.update("Active", bad).is_err());
}
assert_eq!(
fs::read(&path).unwrap(),
accepted_bytes,
"a refused update writes nothing"
);
}
#[test]
fn c005_fr002_deleting_a_view_touches_no_issue_file() {
let (dir, mut config) = project(RAW);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let issues_dir = dir.path().join("docs/issues");
fs::create_dir_all(&issues_dir).unwrap();
let sentinel = issues_dir.join("2026-01-01-1.md");
let contents = "---\nnumber: 1\ntitle: Sentinel\nstatus: todo\n---\n\n## History\n\n- 2026-01-01: created\n";
fs::write(&sentinel, contents).unwrap();
let before = fs::read(&sentinel).unwrap();
let id = {
let mut service = ViewService::new(&mut config, &known, &path);
let id = service
.create(definition("Active", snapshot_plan()))
.unwrap()
.into_value();
service
.mark_active(id.as_str(), Some(ViewSurface::Cli))
.unwrap();
let removed = service
.delete(id.as_str(), DeleteConfirmed::confirmed())
.unwrap()
.into_value();
assert!(removed.marker_cleared());
assert!(service.active().is_none(), "the marker was cleared");
id
};
assert_eq!(
fs::read(&sentinel).unwrap(),
before,
"delete touched no issue byte"
);
let reloaded = Config::load(dir.path()).unwrap();
assert!(reloaded.views.get(&id).is_none(), "the definition is gone");
assert!(reloaded.views.is_empty());
{
let mut service = ViewService::new(&mut config, &known, &path);
assert!(
service
.delete("v-absent", DeleteConfirmed::confirmed())
.is_err()
);
}
assert!(!fs::read_to_string(&path).unwrap().contains("[views."));
}
#[test]
fn c005_fr004_apply_is_read_only_and_marks_the_stable_id() {
let (dir, mut config) = project(RAW);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let plan = snapshot_plan();
let (board, resolution) = board_and_resolution(&config);
let issues = vec![
issue(1, "Alpha", "todo", Some("urgent"), "2026-01-01"),
issue(2, "Beta", "done", Some("urgent"), "2026-01-02"),
issue(3, "Gamma", "todo", None, "2026-01-03"),
];
let corpus = corpus(issues);
let id = {
let mut service = ViewService::new(&mut config, &known, &path);
let id = service
.create(definition("Active", plan.clone()))
.unwrap()
.into_value();
service
.mark_active(id.as_str(), Some(ViewSurface::Cli))
.unwrap();
assert_eq!(service.active().unwrap().view_id(), &id);
let result = service
.apply(id.as_str(), &board, &corpus, &resolution)
.unwrap();
assert_eq!(result.view_id(), Some(&id));
assert_eq!(result.plan(), &plan);
let mut numbers = result.issue_numbers().to_vec();
numbers.sort_unstable();
assert_eq!(numbers, vec![1, 3], "only the todo issues match");
id
};
assert!(
config.views.get(&id).is_some(),
"the definition survives apply"
);
let (bad_dir, mut bad_config) = project(
"issues_dir = \"docs/issues\"\n\n[views.v-bad]\nid = \"v-bad\"\nname = \"Bad\"\n\n[views.v-bad.plan]\nsort = \"nope\"\n",
);
let bad_path = bad_dir.path().join("tix.toml");
let (board, resolution) = board_and_resolution(&bad_config);
let service = ViewService::new(&mut bad_config, &known, &bad_path);
let refusal = service
.apply("v-bad", &board, &corpus, &resolution)
.unwrap_err();
assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidSort);
assert!(
bad_config.views.get(&vid("v-bad")).is_some(),
"nothing was removed"
);
}
#[test]
fn c006_fr004_divergence_is_ordered_and_never_reconciles_live_state() {
let saved = ViewPlan::from_parts(
StructuredFilter {
status: Some("todo".into()),
..StructuredFilter::default()
},
"sort:due",
);
let live = ViewPlan::from_parts(
StructuredFilter {
status: Some("done".into()),
..StructuredFilter::default()
},
"sort:created",
);
let in_sync = ViewDivergence::between(&saved, &saved.clone());
assert!(in_sync.in_sync());
assert!(in_sync.differences().is_empty());
let divergence = ViewDivergence::between(&saved, &live);
assert!(!divergence.in_sync());
let fields: Vec<&str> = divergence.differences().iter().map(|d| d.field()).collect();
assert_eq!(fields, vec!["plan.filter.status", "plan.sort"]);
assert_eq!(divergence.live_plan(), &live, "the live plan is untouched");
let missing = ViewDivergence::missing_marker(&live);
assert!(!missing.in_sync());
assert!(!missing.diagnostics().is_empty());
let deleted = ViewDivergence::deleted(&vid("v-gone"), &live);
assert!(!deleted.in_sync());
let (dir, mut config) = project(RAW);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let id;
{
let mut service = ViewService::new(&mut config, &known, &path);
id = service
.create(definition("Active", saved.clone()))
.unwrap()
.into_value();
service
.mark_active(id.as_str(), Some(ViewSurface::Tui))
.unwrap();
assert!(service.divergence(&saved.clone()).in_sync());
let report = service.divergence(&live);
assert!(!report.in_sync(), "live state diverged from the saved plan");
assert_eq!(report.view_id(), Some(&id));
}
let reloaded = Config::load(dir.path()).unwrap();
assert_eq!(reloaded.views.get(&id).unwrap().plan, saved.to_toml());
assert_eq!(live.sort(), Some("created"));
}
#[test]
fn c007_nfr003_cli_tui_mcp_and_portable_agree_on_plan_and_result() {
let tmp = repo();
let dir = tmp.path();
cli_issue(dir, "Alpha", "todo");
cli_issue(dir, "Beta", "todo");
cli_issue(dir, "Gamma", "done");
let view_id = cli_create_view(dir, "Active", &["--filter", "status:todo", "--sort", "due"]);
let cli_show = cli_stdout(dir, &["view", "show", &view_id]);
let cli_apply = cli_stdout(dir, &["view", "apply", &view_id]);
let mut cli_issues = parse_issues(&cli_apply);
cli_issues.sort_unstable();
assert_eq!(
cli_issues.len(),
2,
"the view selects the two todo issues: {cli_apply}"
);
let config = Config::load(dir).unwrap();
let known: BTreeSet<String> = BTreeSet::new();
let stored = config.views.get(&vid(&view_id)).unwrap();
let reference = ViewPlan::from_toml(&stored.plan).unwrap();
let reference_json = serde_json::to_value(reference.to_toml()).unwrap();
let portable = PortableDocument::from_registry(&config.views, &known, None, None);
let portable_view = portable.view(&view_id).expect("the view is projected");
assert_eq!(
portable_view.normalized_plan(),
Some(&reference.to_toml()),
"the portable normalized plan is the shared plan"
);
assert_eq!(
serde_json::to_value(portable_view.normalized_plan().unwrap()).unwrap(),
reference_json,
"the portable plan JSON is the MCP plan JSON"
);
let mut session = McpSession::start(dir);
session.handshake();
let show = session.call_tool("view_show", &json!({"view": view_id}));
assert!(tool_ok(&show), "{}", tool_text(&show));
assert_eq!(
tool_text(&show).trim_end(),
cli_show.trim_end(),
"`view_show` text is `tix view show` stdout byte for byte"
);
assert_eq!(
tool_structured(&show)["plan"].clone(),
reference_json,
"the MCP plan is the shared normalized plan"
);
let apply = session.call_tool("view_apply", &json!({"view": view_id}));
assert!(tool_ok(&apply), "{}", tool_text(&apply));
assert_eq!(tool_structured(&apply)["plan"].clone(), reference_json);
let mut mcp_issues: Vec<u32> =
serde_json::from_value(tool_structured(&apply)["issues"].clone()).unwrap();
mcp_issues.sort_unstable();
assert_eq!(
mcp_issues, cli_issues,
"MCP and CLI return the same issue set"
);
assert_eq!(
tool_structured(&apply)["count"].as_u64(),
Some(cli_issues.len() as u64)
);
session.close();
let mut config = load_config(dir);
config.issues_dir = dir.join("docs/issues");
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('v')).unwrap();
assert!(matches!(app.mode, Mode::Views), "`v` opens the view list");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let mut tui_issues = board_numbers(&app);
tui_issues.sort_unstable();
assert_eq!(tui_issues, cli_issues, "the TUI board is the shared result");
assert_eq!(app.active_view_id(), Some(&vid(&view_id)));
let divergence = app
.view_divergence()
.expect("the marker names a stored view");
assert!(
divergence.in_sync(),
"the TUI's live plan is the saved normalized plan"
);
}
fn filtering_budget() -> Duration {
if cfg!(debug_assertions) {
Duration::from_millis(250)
} else {
Duration::from_millis(100)
}
}
fn large_corpus() -> Corpus {
let issues: Vec<Issue> = (1..=10_000)
.map(|number| {
let status = if number % 2 == 0 { "done" } else { "todo" };
issue(
number,
&format!("Issue {number}"),
status,
None,
"2026-01-01",
)
})
.collect();
corpus(issues)
}
#[test]
fn nfr001_applying_a_plan_over_10000_issues_stays_within_the_structural_budget() {
let _performance = super::serialized_performance_test();
let config = Config::default();
let (board, resolution) = board_and_resolution(&config);
let corpus = large_corpus();
let plan = todo_plan().with_sort("due");
let warm = plan.apply(&board, &config, &corpus, &resolution).unwrap();
assert_eq!(warm.issue_numbers().len(), 5_000);
let started = Instant::now();
let result = plan.apply(&board, &config, &corpus, &resolution).unwrap();
let elapsed = started.elapsed();
eprintln!("NFR-001: applying a saved plan over 10,000 issues took {elapsed:?}");
assert_eq!(
result.issue_numbers().len(),
5_000,
"the filter selected exactly the 5,000 todo issues"
);
assert!(
elapsed < filtering_budget(),
"one pass over 10,000 issues took {elapsed:?}, over the {:?} budget",
filtering_budget()
);
}
#[test]
fn nfr001_control_the_10000_issue_workload_is_real_and_not_skipped() {
let _performance = super::serialized_performance_test();
let config = Config::default();
let (board, resolution) = board_and_resolution(&config);
let corpus = large_corpus();
assert_eq!(corpus.issues.len(), 10_000);
let all = ViewPlan::default();
let warm = all.apply(&board, &config, &corpus, &resolution).unwrap();
assert_eq!(
warm.issue_numbers().len(),
10_000,
"an empty plan returns the whole corpus"
);
let started = Instant::now();
let everything = all.apply(&board, &config, &corpus, &resolution).unwrap();
let elapsed = started.elapsed();
eprintln!("NFR-001 control: an all-matching plan over 10,000 issues took {elapsed:?}");
assert_eq!(
everything.issue_numbers().len(),
10_000,
"an empty plan returns the whole corpus"
);
assert!(
elapsed < filtering_budget(),
"the control pass also stays in budget"
);
let narrowed = todo_plan()
.apply(&board, &config, &corpus, &resolution)
.unwrap();
assert_eq!(narrowed.issue_numbers().len(), 5_000);
assert!(narrowed.issue_numbers().len() < everything.issue_numbers().len());
}
#[test]
fn nfr002_c001_unknown_metadata_round_trips_through_file_and_portable_json() {
let raw = "\
issues_dir = \"docs/issues\"
[views.v-platform]
id = \"v-platform\"
name = \"Platform\"
colour = \"red\"
tags = [\"a\", \"b\"]
[views.v-platform.scope]
kind = \"repository\"
[views.v-platform.plan]
sort = \"due\"
mystery = \"kept\"
[views.v-platform.plan.filter]
status = \"todo\"
future_criterion = \"preserved\"
";
let (dir, config) = project(raw);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let stored = config.views.get(&vid("v-platform")).unwrap();
assert_eq!(
stored.extensions.get("colour"),
Some(&toml::Value::String("red".into()))
);
assert!(stored.extensions.contains_key("tags"));
let registry_diagnostics = config.views.diagnostics(&known);
assert!(
registry_diagnostics
.iter()
.any(|diagnostic| diagnostic.kind() == ViewDiagnosticKind::UnknownMetadata),
"unknown record metadata is diagnosed"
);
let plan = ViewPlan::from_toml(&stored.plan).unwrap();
assert_eq!(
plan.extensions().get("mystery"),
Some(&toml::Value::String("kept".into()))
);
assert!(
plan.diagnostics()
.iter()
.any(|diagnostic| diagnostic.field() == Some("plan.extensions.mystery"))
);
config.save(&path).unwrap();
let reloaded = Config::load(dir.path()).unwrap();
assert_eq!(reloaded, config, "load/save preserves every unknown field");
let written = fs::read_to_string(&path).unwrap();
assert!(written.contains("colour"), "{written}");
assert!(written.contains("mystery"), "{written}");
assert!(written.contains("future_criterion"), "{written}");
let portable = PortableDocument::from_registry(&reloaded.views, &known, None, None);
let projected = portable.view("v-platform").unwrap();
assert_eq!(
projected.extensions.get("colour"),
Some(&toml::Value::String("red".into()))
);
let json = portable.to_json().unwrap();
let parsed = PortableDocument::from_json(&json).unwrap();
assert_eq!(parsed, portable, "portable JSON round trip loses nothing");
assert_eq!(
parsed.to_json().unwrap(),
json,
"portable JSON is byte-stable"
);
}
#[test]
fn nfr002_c007_the_portable_document_round_trips_marker_plan_and_diagnostics() {
let (dir, mut config) = project(RAW);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let plan = snapshot_plan();
let id = {
let mut service = ViewService::new(&mut config, &known, &path);
service
.create(definition("Active", plan.clone()))
.unwrap()
.into_value()
};
let marker = ActiveViewMarker::new(vid(id.as_str()), plan.clone(), Some(ViewSurface::Mcp));
let divergence = ViewDivergence::between(&plan, &plan);
let document =
PortableDocument::from_registry(&config.views, &known, Some(&marker), Some(&divergence));
let json = document.to_json().unwrap();
let round_tripped = PortableDocument::from_json(&json).unwrap();
assert_eq!(round_tripped, document);
assert_eq!(round_tripped.to_json().unwrap(), json, "byte-stable");
let projected_marker = document.active().expect("the marker is projected");
assert_eq!(projected_marker.view_id, id.as_str());
assert_eq!(projected_marker.applied_plan, plan.to_toml());
assert_eq!(projected_marker.source.as_deref(), Some("mcp"));
let projected_divergence = document.divergence().expect("divergence is projected");
assert!(projected_divergence.in_sync);
assert!(projected_divergence.differences.is_empty());
let mut refreshed = round_tripped.clone();
refreshed.refresh_diagnostics();
assert_eq!(refreshed, round_tripped);
assert_eq!(
document.view(id.as_str()).unwrap().normalized_plan(),
Some(&plan.to_toml())
);
}
#[test]
fn nfr003_t042_plan_scope_marker_and_diagnostics_match_across_the_shared_dtos() {
let raw = "\
issues_dir = \"docs/issues\"
[views.v-active]
id = \"v-active\"
name = \"Active\"
colour = \"red\"
[views.v-active.scope]
kind = \"repository\"
[views.v-active.plan]
sort = \"due\"
[views.v-active.plan.filter]
status = \"todo\"
";
let (dir, mut config) = project(raw);
let path = dir.path().join("tix.toml");
let known = projects(&[]);
let plan = ViewPlan::from_parts(
StructuredFilter {
status: Some("todo".into()),
..StructuredFilter::default()
},
"sort:due",
);
let marker = ActiveViewMarker::new(vid("v-active"), plan.clone(), Some(ViewSurface::Cli));
let service_diagnostics = {
let service = ViewService::new(&mut config, &known, &path);
let detail = service.show("v-active").unwrap();
assert_eq!(detail.plan(), &plan, "the service plan is the typed plan");
detail.diagnostics().to_vec()
};
let document = PortableDocument::from_registry(&config.views, &known, Some(&marker), None);
let projected = document.view("v-active").unwrap();
let stored = config.views.get(&vid("v-active")).unwrap();
assert_eq!(
projected.normalized_plan(),
Some(&ViewPlan::from_toml(&stored.plan).unwrap().to_toml())
);
assert_eq!(projected.scope.kind, stored.scope.kind.as_str());
assert_eq!(projected.scope.project_id, stored.scope.project_id);
assert_eq!(projected.scope.owner, stored.scope.owner);
assert_eq!(projected.scope.shared_with, stored.scope.shared_with);
assert_eq!(
document.active().unwrap().applied_plan,
marker.applied_plan().to_toml()
);
assert_eq!(
projected.diagnostics().len(),
service_diagnostics.len(),
"the portable and service diagnostics agree in number"
);
for (portable_diagnostic, service_diagnostic) in
projected.diagnostics().iter().zip(&service_diagnostics)
{
assert_eq!(
portable_diagnostic.kind(),
service_diagnostic.kind().as_str()
);
assert_eq!(portable_diagnostic.field(), service_diagnostic.field());
assert_eq!(portable_diagnostic.message(), service_diagnostic.message());
assert_eq!(portable_diagnostic.remedy(), service_diagnostic.remedy());
}
assert!(
projected
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.kind() == ViewDiagnosticKind::UnknownMetadata.as_str()),
"the unknown record field is diagnosed on both sides"
);
}
fn cli() -> CliCommand {
CliCommand::cargo_bin("tix").unwrap()
}
fn repo() -> TempDir {
let tmp = TempDir::new().unwrap();
cli().current_dir(tmp.path()).arg("init").assert().success();
tmp
}
fn cli_issue(dir: &Path, title: &str, status: &str) {
cli()
.current_dir(dir)
.args([
"create",
"--title",
title,
"--assignee",
"alice",
"--status",
status,
])
.assert()
.success();
}
fn cli_stdout(dir: &Path, args: &[&str]) -> String {
let out = cli()
.current_dir(dir)
.args(args)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).unwrap()
}
fn cli_create_view(dir: &Path, name: &str, extra: &[&str]) -> String {
let mut args = vec!["view", "create", "--name", name];
args.extend_from_slice(extra);
let text = cli_stdout(dir, &args);
text.lines()
.find_map(|line| line.strip_prefix("Created "))
.unwrap_or_else(|| panic!("no `Created <id>` line in {text:?}"))
.trim()
.to_string()
}
fn parse_issues(text: &str) -> Vec<u32> {
let line = text
.lines()
.find_map(|line| line.strip_prefix("issues: "))
.unwrap_or_else(|| panic!("no `issues:` line in {text:?}"))
.trim();
if line == "(none)" {
return Vec::new();
}
line.split(',')
.map(|number| number.trim().parse().unwrap())
.collect()
}
fn load_config(dir: &Path) -> Config {
Config::load(dir).unwrap()
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn press_char(c: char) -> KeyEvent {
press(KeyCode::Char(c))
}
fn board_numbers(app: &App) -> Vec<u32> {
app.board
.columns
.iter()
.chain(app.board.hidden.iter())
.chain(std::iter::once(&app.board.mismatch))
.flat_map(|column| column.cards.iter().map(|card| card.number))
.collect()
}
fn tix_bin() -> String {
env!("CARGO_BIN_EXE_tix").to_string()
}
struct Reader {
thread: std::thread::JoinHandle<()>,
}
fn spawn_reader(stdout: std::process::ChildStdout, tx: mpsc::Sender<String>) -> Reader {
let thread = std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
if tx
.send(line.trim_end_matches(['\n', '\r']).to_string())
.is_err()
{
break;
}
}
}
}
});
Reader { thread }
}
struct McpSession {
child: Child,
stdin: Option<ChildStdin>,
rx: mpsc::Receiver<String>,
reader: Option<Reader>,
id: Cell<u32>,
}
impl McpSession {
fn start(repo: &Path) -> Self {
let mut child = Command::new(tix_bin())
.arg("mcp")
.current_dir(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn tix mcp");
let stdin = child.stdin.take().expect("stdin piped");
let stdout = child.stdout.take().expect("stdout piped");
let (tx, rx) = mpsc::channel();
let reader = spawn_reader(stdout, tx);
Self {
child,
stdin: Some(stdin),
rx,
reader: Some(reader),
id: Cell::new(1),
}
}
fn write_line(&mut self, line: &str) {
writeln!(self.stdin.as_mut().unwrap(), "{line}").unwrap();
self.stdin.as_mut().unwrap().flush().unwrap();
}
fn next_id(&self) -> u32 {
let id = self.id.get();
self.id.set(id + 1);
id
}
fn recv_json(&self) -> Value {
let line = self
.rx
.recv_timeout(Duration::from_secs(10))
.expect("no MCP response within 10 s");
serde_json::from_str(&line).unwrap_or_else(|e| panic!("invalid JSON: {e}\nraw: {line}"))
}
fn handshake(&mut self) {
let id = self.next_id();
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}}
});
self.write_line(&serde_json::to_string(&message).unwrap());
let response = self.recv_json();
assert_eq!(response["result"]["serverInfo"]["name"], "tix");
self.write_line(
&serde_json::to_string(
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
)
.unwrap(),
);
}
fn call_tool(&mut self, name: &str, args: &Value) -> Value {
let id = self.next_id();
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {"name": name, "arguments": args}
});
self.write_line(&serde_json::to_string(&message).unwrap());
let response = self.recv_json();
response
.get("result")
.or(response.get("error"))
.cloned()
.unwrap()
}
fn close(mut self) {
drop(self.stdin.take());
let _ = self.child.wait();
if let Some(reader) = self.reader.take() {
let _ = reader.thread.join();
}
}
}
fn tool_result(value: &Value) -> &Value {
value.get("result").or(value.get("error")).unwrap_or(value)
}
fn tool_text(value: &Value) -> &str {
tool_result(value)["content"][0]["text"].as_str().unwrap()
}
fn tool_ok(value: &Value) -> bool {
tool_result(value).get("isError") != Some(&Value::Bool(true))
}
fn tool_structured(value: &Value) -> &Value {
&tool_result(value)["structuredContent"]
}