tix-tracker 0.12.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
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
use std::path::Path;

use crate::assignee::git_identity;
use crate::board_store::BoardStore;
use crate::config::{Config, PlanningConfig, PlanningCycle, Team, starter_board};
use crate::error::{Result, TixError};
use crate::planning::Planner;

/// `tix init`: write the default `tix.toml` at `cwd`, seed the planning
/// registry, write the default board file, then create the first `Backlog`
/// milestone file. Refuses to overwrite an existing `tix.toml` (per
/// `contracts/tix-toml.md` §init).
pub fn run(cwd: &Path) -> Result<()> {
    run_with(cwd, None)
}

/// `tix init` with an explicit beads mode.
///
/// `beads` is the `--beads` value. `None` means "decide it here": when `bd` and a
/// beads workspace are both reachable **and** there is a terminal to ask on, the
/// user is offered the modes; anything else — no `bd`, no workspace, or no
/// terminal — leaves `off` without a prompt, so a non-interactive `tix init`
/// never blocks and never writes a section nobody asked for.
pub fn run_with(cwd: &Path, beads: Option<&str>) -> Result<()> {
    let path = cwd.join("tix.toml");
    if path.exists() {
        return Err(TixError::Config(format!(
            "{} already exists — refusing to overwrite (delete it first if you want to re-init)",
            path.display()
        )));
    }
    let mut config = Config::starter();
    seed_planning(&mut config);
    let mode = resolve_beads_mode(&config, beads)?;
    config.beads.mode = mode;
    // The board is written to its file store first; once `docs/boards/` exists
    // it is authoritative, so `tix.toml` must not carry a second copy.
    seed_board(cwd)?;
    write_init_config(&config, &path)?;
    seed_keybindings(cwd)?;
    seed_milestone(cwd)?;
    seed_tags(cwd, &config)?;
    seed_gitignore(cwd)?;
    println!("Created {}", path.display());
    apply_beads(cwd, &config, mode)?;
    Ok(())
}

/// Decide the beads mode `tix init` records.
///
/// Detection is read-only and runs only `bd version`/`bd where`/`bd config get`.
/// A `bd` that is missing, or a repository with no workspace, is **not** an error
/// here: it is the common case, and `tix init` says so once and keeps `off`.
fn resolve_beads_mode(config: &Config, beads: Option<&str>) -> Result<crate::config::BeadsMode> {
    use crate::config::BeadsMode;
    use std::io::IsTerminal as _;

    if let Some(key) = beads {
        return BeadsMode::from_key(key).ok_or_else(|| {
            TixError::InvalidInput(format!(
                "unknown --beads `{key}` — use off, mirror, or sync"
            ))
        });
    }
    if !std::io::stdin().is_terminal() {
        return Ok(BeadsMode::Off);
    }
    let detection = crate::sync::beads::detect(&crate::sync::ProcessRunner, config);
    if !detection.workspace {
        if detection.available {
            eprintln!("tix: {}", detection.detail);
        }
        return Ok(BeadsMode::Off);
    }
    eprintln!("tix: {}", detection.detail);
    eprint!("tix: keep this board in step with beads? [off/mirror/sync] (off) ");
    let mut line = String::new();
    std::io::stdin()
        .read_line(&mut line)
        .map_err(TixError::Io)?;
    match line.trim() {
        "" => Ok(BeadsMode::Off),
        key => BeadsMode::from_key(key).ok_or_else(|| {
            TixError::InvalidInput(format!(
                "unknown beads mode `{key}` — use off, mirror, or sync"
            ))
        }),
    }
}

/// Act on the recorded mode: nothing for `off`, a line for `mirror`, and the
/// `beads → tix` pipe for `sync`.
///
/// `sync` without a beads workspace under this root is refused rather than
/// half-installed: the hooks live in `.beads/hooks/`, and writing them somewhere
/// beads will never look would be a silent no-op.
fn apply_beads(cwd: &Path, config: &Config, mode: crate::config::BeadsMode) -> Result<()> {
    use crate::config::BeadsMode;
    match mode {
        BeadsMode::Off => Ok(()),
        BeadsMode::Mirror => {
            println!("beads: mirror recorded (read-only) — `tix beads status` reads the workspace");
            Ok(())
        }
        BeadsMode::Sync => {
            let beads_dir = cwd.join(".beads");
            if !beads_dir.is_dir() {
                return Err(TixError::Config(format!(
                    "beads sync needs a workspace here: {} does not exist — run `bd init` first",
                    beads_dir.display()
                )));
            }
            let written = crate::sync::beads::install_hooks(cwd, &beads_dir, &config.beads.binary)?;
            println!(
                "beads: sync recorded; installed {} hook(s) in {}",
                written.len(),
                beads_dir.join("hooks").display()
            );
            Ok(())
        }
    }
}

/// The `.gitignore` marker pair. The `AGENTS.md` block uses HTML comments
/// (`<!-- tix:start -->`), but `.gitignore` is a shell-comment file, so the same
/// conventions in `commands::upsert_block` are used with `#` markers, and the rest
/// of the file is never touched.
const GITIGNORE_START: &str = "# tix:start";
const GITIGNORE_END: &str = "# tix:end";

/// Ensure `.gitignore` ignores the files `tix` generates for itself — the
/// centralized `.tix-backups/` directory beside the issues directory (#1581),
/// the local `NumberClaim` ledger `tix create` writes (#1587/#1590), plus the
/// legacy `<name>.md.tixbak` (the old TUI editor/repair/conflict path) and
/// `<name>.md.bak` (the old import overwrite path) that an older release left
/// beside an issue — inside a marker-delimited block so a re-run replaces the
/// block in place and leaves the project's own rules alone.
///
/// The ledger is seeded as `**/.tix-numbers/` rather than `.tix-numbers/`: the
/// claims directory is derived from the configured issues directory, so it can
/// sit at any depth, and the glob also covers the default `docs/.tix-numbers/`.
fn seed_gitignore(cwd: &Path) -> Result<()> {
    let path = cwd.join(".gitignore");
    let block = format!(
        "{GITIGNORE_START}\n.tix-backups/\n**/.tix-numbers/\n*.tixbak\n*.md.bak\n{GITIGNORE_END}"
    );
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let updated = crate::commands::upsert_block(&existing, &block, GITIGNORE_START, GITIGNORE_END);
    if updated == existing {
        return Ok(());
    }
    crate::commands::atomic_write(&path, &updated)?;
    println!("Updated {}", path.display());
    Ok(())
}

/// Write `docs/boards/default.md` with the starter board and a short body
/// documenting the sparse `card_order`, idempotently: a pre-existing file is
/// never overwritten. The `Default` cycle is implicit and needs no
/// `docs/cycles/` directory.
fn seed_board(cwd: &Path) -> Result<()> {
    let store = BoardStore::new(cwd);
    if store.dir().join("default.md").exists() {
        return Ok(());
    }
    store.save(
        "default",
        &starter_board(),
        Some("Only cards whose ordering differs from the default are listed in `card_order`."),
    )
}

/// Create the tag directory (tix #1530) if absent, so `docs/tags/` exists and is
/// discoverable. An empty store is a valid store — no records are written, and a
/// re-run leaves existing files alone.
fn seed_tags(cwd: &Path, config: &Config) -> Result<()> {
    let dir = cwd.join(config.tags_dir());
    if !dir.exists() {
        std::fs::create_dir_all(&dir)?;
        println!("Created {}", dir.display());
    }
    Ok(())
}

/// Write the initial `tix.toml`. Board and cycle records live in their
/// `docs/` stores, so the serialized file omits the `[kanban]` section and
/// carries only the settings that remain authoritative here.
fn write_init_config(config: &Config, path: &Path) -> Result<()> {
    let dir = match path.parent() {
        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
        _ => Path::new(".").to_path_buf(),
    };
    std::fs::create_dir_all(&dir)?;

    let mut disk = config.clone();
    disk.kanban = crate::config::KanbanSection::default();
    let mut contents = toml::to_string_pretty(&disk)
        .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
    // tix #1632: the TUI's startup tips are on by default, and the key that turns
    // them off is written explicitly so the setting is discoverable where the rest
    // of the configuration lives. The serializer omits a `true` value (so a legacy
    // file still round-trips byte-identically), which is why this is spliced in
    // rather than left to the serializer: a new repository is the one file that
    // must name it. It goes before the first table header, since a bare key after
    // one would be read as a member of that table.
    match contents.find("\n[") {
        Some(index) => contents.insert_str(index + 1, "tips_on_startup = true\n\n"),
        None => contents.push_str("\ntips_on_startup = true\n"),
    }
    contents.push_str(
        "\n# Optional new-issue templates for the F4 New Issue mode; selecting one\n\
         # pre-fills the form. Every field is optional; the title stays required.\n\
         # See docs/for-users/new-issue.md.\n\
         # [templates.bug]\n\
         # labels = [\"bug\"]\n\
         # body = \"## Steps to reproduce\\n\\n## Expected\\n\"\n",
    );
    contents.push_str(
        "\n# Frontmatter keys this project uses on purpose. An undeclared key is\n\
         # preserved on write but `tix lint` warns about it (usually a typo);\n\
         # declaring it here once stops the warning. Top-level names only.\n\
         # [custom_attributes]\n\
         # keys = [\"build_id\", \"reviewed_by\"]\n",
    );
    contents.push_str(
        "\n# Configurable on-disk issue layout, relative to issues_dir. Directory\n\
         # tokens: {assignee} {type} {status} {priority} {cycle} {milestone}.\n\
         # Filename tokens: {id} {date} {slug} {number} {title}. Defaults shown.\n\
         # `move_on_change` relocates a file when a templated value changes.\n\
         # Inspect with `tix layout show`; relocate with `tix layout migrate`.\n\
         # [layout]\n\
         # directory = \"{assignee}/{type}/{status}\"\n\
         # filename = \"{date}-{id}-{slug}.md\"\n\
         # move_on_change = true\n",
    );
    contents.push_str(
        "\n# Child repositories this project coordinates (tix #1529). A parent-only\n\
         # list, read one level deep: each name is how the repository appears in\n\
         # the F6 Repos mode, and each value is that child's project root (the\n\
         # directory holding its tix.toml), relative to this file. Activating one\n\
         # runs tix inside it; exiting returns here.\n\
         # [repos]\n\
         # tix-tracker = \"../tix\"\n\
         # website = \"../website\"\n\
         #\n\
         # When this repository is itself a managed child, name the parent so the\n\
         # F6 Repos mode can jump back to it.\n\
         # parent_repo = \"../parent\"\n",
    );
    contents.push_str(
        "\n# Per-type accent colors (tix #1530). Each shipped type already has a\n\
         # default color; override any of them here. A type chip renders in its\n\
         # color wherever it appears.\n\
         # [type_colors]\n\
         # bug = \"red\"\n\
         # feature = \"green\"\n\
         #\n\
         # Tag colors live in `docs/tags/`, one `*.md` per tag with a `name:` and\n\
         # an optional `color:`. See docs/key-bindings.md and the tags directory.\n",
    );

    let file_name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("tix.toml");
    let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));

    let write_result = (|| -> std::io::Result<()> {
        let mut f = std::fs::File::create(&tmp)?;
        use std::io::Write;
        f.write_all(contents.as_bytes())?;
        f.sync_all()?;
        Ok(())
    })();
    if let Err(e) = write_result {
        let _ = std::fs::remove_file(&tmp);
        return Err(TixError::Io(e));
    }

    if let Err(e) = std::fs::rename(&tmp, path) {
        let _ = std::fs::remove_file(&tmp);
        return Err(TixError::Io(e));
    }
    Ok(())
}

/// Scaffold the optional TUI keybinding overrides beside `tix.toml`.
///
/// The file is entirely commented, so initialization still leaves every built-in
/// chord in force. An existing file is never overwritten.
fn seed_keybindings(cwd: &Path) -> Result<()> {
    let path = cwd.join(crate::keybindings::KEYBINDINGS_FILE);
    if path.exists() {
        return Ok(());
    }
    let contents = "\
# Optional sparse TUI keybinding overrides. Every omitted mode and action keeps\n\
# its built-in chord. See docs/key-bindings.md for the complete reference.\n\
#\n\
# [board]\n\
# new_issue = [\"ctrl+n\"]\n";
    crate::commands::atomic_write(&path, contents)?;
    println!("Created {}", path.display());
    Ok(())
}

/// Fill the default config's planning surface: the milestones dir, the
/// auto-cycle counter at `1`, the implicit `Default` cycle (no end date, so
/// it is always current), the picker presets, and — when a git identity can
/// be read — a `default` team containing that identity (FR-002).
///
/// A user who never configures cycles keeps everything in `Default`; the
/// `docs/cycles/` directory is created only when a second cycle is added.
fn seed_planning(config: &mut Config) {
    config.milestones_dir = Some("docs/milestones".into());
    config.planning = PlanningConfig {
        default_cycle: Some(crate::cycle_store::DEFAULT_CYCLE_NAME.into()),
        default_milestone: Some("Backlog".into()),
        default_team: None,
        ..PlanningConfig::default()
    };
    config.cycles.insert(
        crate::cycle_store::DEFAULT_CYCLE_NAME.into(),
        PlanningCycle::default(),
    );
    if let Some(identity) = git_identity() {
        config.teams.insert(
            "default".into(),
            Team {
                members: vec![identity.to_string()],
            },
        );
        config.planning.default_team = Some("default".into());
    }
}

/// Create the initial `Backlog` milestone file
/// (`contracts/milestone-file-format.md`), idempotently: a pre-existing
/// file is never overwritten (`create_milestone_file` refuses, and produces
/// an [`TixError::InvalidInput`], which we absorb).
fn seed_milestone(cwd: &Path) -> Result<()> {
    let planner = Planner::from_config(Config::default(), cwd.to_path_buf());
    match planner.create_milestone_file(
        "Backlog",
        None,
        Some("Default milestone for unscheduled work"),
    ) {
        Ok(_) => Ok(()),
        Err(TixError::InvalidInput(_)) => Ok(()),
        Err(e) => Err(e),
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn seed_gitignore_creates_the_block_and_keeps_other_rules() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join(".gitignore");
        fs::write(&path, "/target\nnode_modules\n").unwrap();

        seed_gitignore(tmp.path()).unwrap();

        let first = fs::read_to_string(&path).unwrap();
        assert!(
            first.contains(
                "# tix:start\n.tix-backups/\n**/.tix-numbers/\n*.tixbak\n*.md.bak\n# tix:end"
            ),
            "the marked block is written: {first}"
        );
        assert!(
            first.contains("/target\nnode_modules"),
            "unrelated rules are kept: {first}"
        );

        // A second run is a no-op: the block already matches byte for byte.
        seed_gitignore(tmp.path()).unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), first);
    }

    #[test]
    fn seed_gitignore_replaces_a_stale_block_in_place() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join(".gitignore");
        fs::write(&path, "keep\n# tix:start\nold-rule\n# tix:end\ntail\n").unwrap();

        seed_gitignore(tmp.path()).unwrap();

        let got = fs::read_to_string(&path).unwrap();
        assert!(
            got.contains(
                "# tix:start\n.tix-backups/\n**/.tix-numbers/\n*.tixbak\n*.md.bak\n# tix:end"
            ),
            "the block is refreshed: {got}"
        );
        assert!(!got.contains("old-rule"), "the stale rule is gone: {got}");
        assert!(
            got.contains("keep") && got.contains("tail"),
            "text around the block survives: {got}"
        );
    }

    #[test]
    fn init_needs_no_labels_key_because_decision_pending_is_builtin() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("tix.toml");
        write_init_config(&Config::starter(), &path).unwrap();

        let got = fs::read_to_string(&path).unwrap();
        // No `labels` key: the builtin resolves without one, and writing a
        // brand-new key would make the file unreadable to a released binary whose
        // `Config` denies unknown fields.
        assert!(
            !got.lines()
                .any(|line| line.trim_start().starts_with("labels =")),
            "`tix init` writes no labels key: {got}"
        );
        assert!(
            Config::starter()
                .labels()
                .contains(&"decision-pending".to_string()),
            "the builtin label resolves without any key"
        );
    }
}