tix-tracker 0.5.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
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;

/// `tix view <issue>`: print the full issue (frontmatter + `---` + body)
/// verbatim to stdout. No trailing newline beyond what the issue file
/// contains.
///
/// `--show relationships` appends the service-derived block: every relationship
/// touching this issue, its transitive chain, and its blocked reason. It is
/// behind a flag because the default output is the **file**, and
/// `tix view <n> > copy.md` is a thing people do. Appending derived state
/// unconditionally would make the output no longer a valid issue file, which
/// would trade a readability feature for a round-trip regression.
pub fn run(store: &Store, raw: &str, show: Option<&str>) -> Result<()> {
    // Validate `--show` **before** anything is printed.
    //
    // The order matters for the stdout contract. Printing first and validating
    // second means `tix view 0 --show bogus` writes the whole issue file to
    // stdout and *then* exits non-zero — so a consumer that redirects stdout has
    // a file that looks complete and a status code that says it failed, and
    // cannot tell which to believe. A rejected invocation must produce no output.
    let want_relationships = link::wants_relationships(show)?;
    let issue = get(store, raw)?;
    print!("{}", issue.to_file_contents());
    // The hierarchy block is **unconditional** — `contracts/cli-surface.md` §2 says
    // "detail includes a parent block and, for a parent, its rollup", with no flag
    // in the spelling, unlike the relationships block beside it. A parent is
    // structural rather than extra detail, and behind a flag `tix show` on a child
    // would silently omit the one fact that explains why it is a child.
    //
    // Every figure comes from the shared `HierarchyFacts`, so this block and
    // `tix parent show` cannot describe different snapshots (parity contract §2).
    {
        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));
    }
    // The category block, **unconditional**, for the same reason the hierarchy
    // block is: `contracts/cli-surface.md` §2 puts the category in the detail view
    // with no flag, and the category is the answer to "what stage is this in?",
    // which a status column alone does not give on a board whose columns and
    // categories differ.
    //
    // Appended *after* the hierarchy block and before relationships, so the file
    // comes first, then the structural facts, then the extra detail — the order a
    // reader scans them in.
    {
        let (_, resolution) = crate::workflow::active_resolution(&store.config);
        print!("{}", category_block(&issue, &resolution));
    }
    if want_relationships {
        // One graph for all three derived reads, so the relationships, the
        // chain, and the blocked reason cannot describe different snapshots.
        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(())
}

/// The derived category block appended to `tix show`'s output (WP08 T036).
///
/// Two lines, and the second is the one that matters:
///
/// - `category: <key> (<label>)` — the **key** because a script wants a stable
///   word, the **label** because a human reads it. The list column shows the label
///   only (`--show category` is a narrow column); the detail view has room for
///   both, and `t035_the_category_label_is_identical_on_every_path` pins the label
///   to the same string both render.
/// - `workflow: <what this category means>` — `complete`, `terminal, not complete`,
///   or `in progress`. T036 step 2 asks for the completion context precisely so a
///   reader cannot conclude from a `canceled` issue that the work was finished:
///   `canceled` is terminal and is **not** complete, and a detail view that said
///   only `Canceled` would leave that to be guessed.
///
/// The label comes from the service's `label()`, never from a local string, and
/// the *unmapped* case is spelled the way the list column spells it (`(unmapped)`)
/// rather than blanked or turned into an error — T036 step 3.
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")
}

/// The view core (WP03): resolve `<issue>` and load the [`Issue`] record.
///
/// This is what MCP's `get_issue` tool (WP04) calls. Side-effect policy
/// (C-005): the core never reads `stdin`/writes `stdout`; the CLI printer in
/// [`run`] renders the record. Selector resolution and ambiguity handling
/// reuse `commands/mod.rs` unchanged (Directive 024).
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![],
            files: vec![],
            cycles: None,
            milestones: None,
            reviewer: None,
            start_date: None,
            due_date: None,
            created: "2026-01-01".to_string(),
            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");
        // A bare filename fragment matches nothing; a title fragment matching
        // two issues is ambiguous per contracts/cli.md.
        let err = get(&store, "Shared").unwrap_err();
        assert!(
            matches!(err, crate::error::TixError::InvalidInput(_)),
            "ambiguous selector must be an InvalidInput error, got {err}"
        );
    }

    // ---- WP03: `--show relationships` --------------------------------

    #[test]
    fn show_relationships_is_off_by_default_so_the_output_is_still_the_file() {
        // The reason the block is behind a flag: `tix view <n> > copy.md` has to
        // keep producing a valid issue 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() {
        // The regression `Store::relationships_for` was written to prevent: a
        // relationship is stored **once**, in its canonical source's file, so
        // reading this issue's own `relationships` field would show nothing
        // where the service reports one.
        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();

        // The target's own stored field is empty — the edge lives on the source.
        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}"
        );
    }
    // ----------------------------------------------------------------------
    // WP08 T036 — the category in issue detail
    // ----------------------------------------------------------------------

    /// A store on a **mapped** board, so a category exists to render.
    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() {
        // T036 step 2, the reason the second line exists at all. A detail view
        // saying only `Canceled` leaves a reader to guess whether the work was
        // finished, and this is the C-004 rule made visible.
        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() {
        // Two distinct cases, and the block must not conflate them.
        //  - configured but unmapped → the safe default's label
        //  - not configured at all    → `(unmapped)`, spelled as the list column
        //                                 spells it
        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}"
        );
    }
}