use crate::commands::link;
use crate::commands::parse_selector;
use crate::error::Result;
use crate::model::Issue;
use crate::relationships::IssueView;
use crate::relationships::RelationshipGraph;
use crate::storage::Store;
pub fn run(store: &Store, raw: &str, show: Option<&str>) -> Result<()> {
let want_relationships = link::wants_relationships(show)?;
let issue = get(store, raw)?;
print!("{}", issue.to_file_contents());
{
let (index, issues, done_column) = crate::commands::parent::source(store)?;
let facts = crate::commands::parent::facts_for(&index, &issues, issue.number, &done_column);
print!("{}", crate::commands::parent::detail_block(&facts));
}
{
let (_, resolution) = crate::workflow::active_resolution(&store.config);
print!("{}", category_block(&issue, &resolution));
}
if want_relationships {
let (issues, done_column) = store.graph_source()?;
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &done_column);
print!("{}", link::derived_block(&graph, issue.number));
}
Ok(())
}
pub fn category_block(issue: &Issue, resolution: &crate::workflow::CategoryResolution) -> String {
let (key, label, meaning) = match crate::workflow::category_of(issue, resolution) {
Some(c) => {
let meaning = if c.is_complete() {
"complete"
} else if c.is_terminal() {
"terminal, not complete"
} else {
"in progress"
};
(c.key(), c.label(), meaning)
}
None => (
"(unmapped)",
"(unmapped)",
"unmapped column: this board configures no category for it",
),
};
format!("\n## Workflow\n\n- category: {key} ({label})\n- workflow: {meaning}\n")
}
pub fn get(store: &Store, raw: &str) -> Result<Issue> {
let selector = parse_selector(store, raw)?;
store.get(selector)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed(store: &Store, number: u32, title: &str) -> Issue {
let mut issue = Issue {
number,
title: title.to_string(),
status: Status::new("todo"),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".to_string()),
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: "2026-01-01".to_string(),
type_: None,
updated: "2026-01-01".to_string(),
filename: String::new(),
body: "the body".to_string(),
history: crate::model::History::new(),
has_retired_links_key: false,
parent: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
issue
}
#[test]
fn get_returns_the_full_record() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, 1, "Get me");
let issue = get(&store, &seeded.number.to_string()).unwrap();
assert_eq!(issue.title, "Get me");
assert_eq!(issue.body, "the body");
}
#[test]
fn get_selector_miss_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let err = get(&store, "999").unwrap_err();
assert!(
matches!(err, crate::error::TixError::NotFound(_)),
"missing issue must surface as NotFound, got {err}"
);
}
#[test]
fn get_ambiguous_selector_is_an_error() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "Shared title");
seed(&store, 2, "Shared title other");
let err = get(&store, "Shared").unwrap_err();
assert!(
matches!(err, crate::error::TixError::InvalidInput(_)),
"ambiguous selector must be an InvalidInput error, got {err}"
);
}
#[test]
fn show_relationships_is_off_by_default_so_the_output_is_still_the_file() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, 1, "Plain");
let on_disk = fs::read_to_string(store.issues_dir.join(&seeded.filename)).unwrap();
assert_eq!(
get(&store, "1").unwrap().to_file_contents(),
on_disk,
"precondition: the record round-trips to exactly the file's bytes"
);
assert!(!link::wants_relationships(None).unwrap());
}
#[test]
fn the_derived_block_shows_an_edge_stored_on_the_other_endpoint() {
let (tmp, store) = make_store();
let _ = tmp;
let source = seed(&store, 1, "Source");
let target = seed(&store, 2, "Target");
store
.link(
&source.number.to_string(),
&target.number.to_string(),
crate::relationships::RelationKind::Blocks,
)
.unwrap();
let target_record = get(&store, "2").unwrap();
assert!(
target_record.relationships.is_empty(),
"precondition: the edge is stored once, on the source"
);
let (issues, done_column) = store.graph_source().unwrap();
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &done_column);
let block = link::derived_block(&graph, target_record.number);
assert!(
block.contains("Source"),
"the target must see the edge that names it:\n{block}"
);
assert!(
block.contains("blocked"),
"and the blocked reason follows from it:\n{block}"
);
}
fn mapped_store(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let config = crate::config::Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: columns.iter().map(|c| (*c).to_string()).collect(),
done_column: Some(done_column.to_string()),
category_map: map
.iter()
.map(|(c, k)| ((*c).to_string(), (*k).to_string()))
.collect(),
..Default::default()
},
)]),
},
..crate::config::Config::default()
};
config.save(&tmp.path().join("tix.toml")).unwrap();
let config =
crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn five_categories() -> (TempDir, Store) {
mapped_store(
&["triage", "wip", "review", "shipped", "abandoned"],
"shipped",
&[
("triage", "backlog"),
("wip", "active"),
("review", "active"),
("shipped", "completed"),
("abandoned", "canceled"),
],
)
}
fn write(store: &Store, number: u32, title: &str, column: &str) {
let slug = crate::slug::slugify(title);
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{column}\"\n\
labels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\n\
updated: \"2026-01-01\"\n---\n\nbody\n"
),
)
.unwrap();
}
#[test]
fn t036_the_detail_block_carries_the_key_the_label_and_the_completion_context() {
let (_tmp, store) = five_categories();
write(&store, 1, "Shipped", "shipped");
write(&store, 2, "Abandoned", "abandoned");
write(&store, 3, "Wip", "wip");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
for (selector, key, label, meaning) in [
("1", "completed", "Done", "complete"),
("2", "canceled", "Canceled", "terminal, not complete"),
("3", "active", "In progress", "in progress"),
] {
let issue = crate::commands::view::get(&store, selector).unwrap();
let block = crate::commands::view::category_block(&issue, &resolution);
assert!(
block.contains(&format!("- category: {key} ({label})")),
"{selector}: {block}"
);
assert!(
block.contains(&format!("- workflow: {meaning}")),
"{selector}: {block}"
);
}
}
#[test]
fn t036_a_canceled_issue_never_reads_as_complete() {
let (_tmp, store) = five_categories();
write(&store, 1, "Abandoned", "abandoned");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let issue = crate::commands::view::get(&store, "1").unwrap();
let block = crate::commands::view::category_block(&issue, &resolution);
assert!(block.contains("terminal, not complete"), "{block}");
assert!(
!block.contains("workflow: complete"),
"a canceled issue must never say complete: {block}"
);
}
#[test]
fn t036_an_unmapped_column_shows_a_label_not_an_error_and_not_a_blank() {
let (_tmp, store) = mapped_store(
&["triage", "wip", "shipped"],
"shipped",
&[("wip", "active"), ("shipped", "completed")],
);
write(&store, 1, "Defaulted", "triage");
write(&store, 2, "Orphan", "not-a-column");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let defaulted = crate::commands::view::get(&store, "1").unwrap();
let block = crate::commands::view::category_block(&defaulted, &resolution);
assert!(block.contains("unstarted (Not started)"), "{block}");
let orphan = crate::commands::view::get(&store, "2").unwrap();
let block = crate::commands::view::category_block(&orphan, &resolution);
assert!(
block.contains("category: (unmapped) ((unmapped))"),
"{block}"
);
assert!(
block.contains("this board configures no category for it"),
"an unmapped column is stated, not left blank: {block}"
);
}
}