Skip to main content

tix/sync/
beads.rs

1//! Beads (`bd`) interchange — the tix → `bd import` row mapping, its JSONL
2//! encoding, and the `bd export` reader that backs the import leg.
3//!
4//! [beads](https://github.com/gastownhall/beads) is a Dolt-backed, dependency-graph
5//! issue tracker. tix integrates with it **only through the `bd` CLI**: this module
6//! produces the rows `bd import` consumes and parses what `bd export` emits. It
7//! **never** opens the Dolt database, never reads `.beads/embeddeddolt/`, and never
8//! treats `.beads/issues.jsonl` as a source of truth. beads owns its schema (it
9//! refuses to open a database a newer binary migrated), its single-writer embedded
10//! model, and its id assignment; staying on the CLI is how tix respects all three.
11//! Writing SQL is a defect, not a shortcut.
12//!
13//! ## Wire format, pinned against beads 1.3.1
14//!
15//! A row is one JSON object; `bd export` emits one per line (`_type` is `"issue"`),
16//! and `bd import` accepts arrays and JSONL alike. The fields this module writes are
17//! the ones `bd import` documents (`title` is the only required one): `description`,
18//! `status`, `priority`, `issue_type`, `assignee`, `labels`, `dependencies`,
19//! `close_reason`.
20//!
21//! Dependencies are the part worth stating precisely, because beads stores them on
22//! the **dependent** row as `{issue_id, depends_on_id, type}` and derives
23//! blocked-ness (and the ready frontier) from them:
24//!
25//! | tix edge | beads row it lands on | record |
26//! |---|---|---|
27//! | `A blocks B` (`from` blocks `to`) | B's row | `{issue_id: B, depends_on_id: A, type: "blocks"}` |
28//! | `A duplicates B` | A's row | `{issue_id: A, depends_on_id: B, type: "duplicates"}` |
29//! | `A related B` | `min(A,B)`'s row | `{issue_id: A, depends_on_id: B, type: "relates-to"}` |
30//! | `issue.parent = P` | the issue's own row | `{issue_id: issue, depends_on_id: P, type: "parent-child"}` |
31//!
32//! Confirmed by hand against `bd` 1.3.1 (`bd dep add B A` writes `blocks` on B with
33//! `depends_on_id: A`; `bd dep relate` writes `relates-to`; `--parent` writes
34//! `parent-child`), so the table is a measurement, not a guess.
35//!
36//! ## Identity
37//!
38//! A tix issue is exported under a **deterministic** id (`tix-<number>`), which is
39//! what makes a re-run idempotent: `bd import` upserts on `id` ("a new ID inserts,
40//! an existing ID is conditionally rewritten"), and it admits a row whose prefix
41//! differs from the workspace's (`SkipPrefixValidation`), so no separate mapping
42//! table is needed just to avoid duplicates.
43//!
44//! ## What beads cannot hold
45//!
46//! Local fields with no beads column — cycles, milestones, time entries, health,
47//! series, projects, epics, files, reviewer, dates, estimate, and the Spec Kitty
48//! pointers — do **not** transfer. They are reported as [`BeadsDiagnostic`]s rather
49//! than dropped silently (the `unsupported_remote_field` precedent), and the local
50//! value is never touched. Fields that are pure local bookkeeping — `filename`,
51//! `history`, the `commit` a finished issue records, the GitLab/Linear ids — are
52//! neither transferred nor reported: nothing about them is meaningful to beads, and
53//! reporting them once per issue would bury the diagnostics that are.
54
55use std::collections::{BTreeMap, HashSet};
56use std::fmt;
57use std::fmt::Write as _;
58use std::path::{Path, PathBuf};
59
60use serde::{Deserialize, Serialize};
61
62use crate::config::Config;
63use crate::error::{Result, TixError};
64use crate::model::{Issue, Priority};
65use crate::relationships::{RelationKind, Relationship};
66use crate::storage::Store;
67use crate::sync::{CommandOutcome, CommandRunner};
68use crate::workflow::{self, CategoryResolution, WorkflowCategory};
69
70/// The tool name every diagnostic from this module renders under. It is the
71/// string `Tool::as_str` will return once beads joins the `src/sync` [`Tool`]
72/// enum; keeping the spelling here now means the report does not change then.
73pub const TOOL: &str = "beads";
74
75/// The binary shelled out to for a push. Overridden per run once `[beads]`
76/// configuration lands (#1709).
77pub const DEFAULT_BINARY: &str = "bd";
78
79/// The default id prefix for exported rows. The full beads id is
80/// `"<prefix>-<number>"`; see the module docs for why it is deterministic.
81pub const DEFAULT_ID_PREFIX: &str = "tix";
82
83/// The beads `issue_type` vocabulary a tix `type:` may map onto. Anything else
84/// falls back to `task`, because beads validates the field.
85const BEADS_TYPES: [&str; 6] = ["bug", "feature", "task", "epic", "chore", "decision"];
86
87/// The deterministic beads id for a tix issue number.
88#[must_use]
89pub fn beads_id(prefix: &str, number: u32) -> String {
90    format!("{prefix}-{number}")
91}
92
93// ---------------------------------------------------------------------------
94// The row
95// ---------------------------------------------------------------------------
96
97fn issue_record_type() -> String {
98    "issue".to_string()
99}
100
101/// One dependency record, as beads stores it on the dependent's row.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct BeadsDependency {
104    /// The dependent issue (the row this record belongs to).
105    pub issue_id: String,
106    /// The issue depended on.
107    pub depends_on_id: String,
108    /// The relation kind: `blocks`, `duplicates`, `relates-to`, or `parent-child`.
109    #[serde(rename = "type")]
110    pub kind: String,
111}
112
113/// One `bd import` row. Unknown keys present in a `bd export` line are ignored on
114/// read (`deny_unknown_fields` is deliberately off, so beads may add fields), and
115/// absent optional fields are omitted on write.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct BeadsRow {
118    /// `bd export` writes `"_type": "issue"`; `bd import` does not require it.
119    #[serde(rename = "_type", default = "issue_record_type")]
120    pub record_type: String,
121    pub id: String,
122    pub title: String,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub description: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub status: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub priority: Option<u8>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub issue_type: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub assignee: Option<String>,
133    /// `bd export` emits the assignee as `owner`; read either, write `assignee`.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub owner: Option<String>,
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub labels: Vec<String>,
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub dependencies: Vec<BeadsDependency>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub close_reason: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub created_at: Option<String>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub updated_at: Option<String>,
146}
147
148impl BeadsRow {
149    /// The effective assignee of a row read back from beads (`assignee`, else the
150    /// `owner` spelling `bd export` uses).
151    #[must_use]
152    pub fn effective_assignee(&self) -> Option<&str> {
153        self.assignee.as_deref().or(self.owner.as_deref())
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Diagnostics
159// ---------------------------------------------------------------------------
160
161/// One local field beads cannot hold. The shape — and the rendering — match
162/// [`crate::sync::RemoteMappingDiagnostic`] so a reader learns one line format;
163/// the type is separate only until beads joins the `Tool` enum (#1706).
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct BeadsDiagnostic {
166    /// The local issue this is about, when it is about one. `None` for a
167    /// diagnostic about a beads record that maps to no local issue — the import
168    /// path can produce one, the export path never does.
169    ///
170    /// It exists so a caller can report only the diagnostics belonging to what it
171    /// actually sent: a repeat export that sends nothing must not replay three
172    /// hundred "cannot represent" lines about issues it did not touch.
173    pub number: Option<u32>,
174    /// What the diagnostic is about, e.g. `#1705 Investigate beads`.
175    pub subject: String,
176    /// The local field that cannot be represented.
177    pub field: String,
178    /// Human-readable reason, safe for CLI output and the TUI.
179    pub message: String,
180}
181
182impl fmt::Display for BeadsDiagnostic {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        write!(
185            f,
186            "{TOOL}: {} cannot represent this local data ({}) — {}",
187            self.field, self.subject, self.message
188        )
189    }
190}
191
192// ---------------------------------------------------------------------------
193// The mapping
194// ---------------------------------------------------------------------------
195
196/// The banded `bd` status for a tix column, by the workflow **category** service
197/// (never by comparing column names).
198///
199/// `completed` → `closed`, `active` → `in_progress`, everything else → `open`.
200/// `blocked` is deliberately `open`: beads derives blocked-ness from the `blocks`
201/// dependencies this module also sends, so mapping a tix `blocked` column onto
202/// beads' own `blocked` status would set the two mechanisms against each other.
203/// `canceled` → `closed` with `close_reason = "canceled"`, which is how the
204/// distinction survives on a tracker with no canceled status.
205#[must_use]
206pub fn status_for(issue: &Issue, resolution: &CategoryResolution) -> &'static str {
207    match workflow::category_of(issue, resolution) {
208        Some(WorkflowCategory::Completed) => "closed",
209        Some(WorkflowCategory::Canceled) => "closed",
210        Some(WorkflowCategory::Active) => "in_progress",
211        _ => "open",
212    }
213}
214
215/// The `close_reason` to record, or `None`. Only a `canceled` issue carries one:
216/// it is the one category whose distinction `status: closed` alone would erase.
217#[must_use]
218pub fn close_reason_for(issue: &Issue, resolution: &CategoryResolution) -> Option<String> {
219    match workflow::category_of(issue, resolution) {
220        Some(WorkflowCategory::Canceled) => Some("canceled".to_string()),
221        _ => None,
222    }
223}
224
225/// Map a tix priority onto beads' `0..=4` band (0 critical … 4 backlog), or `None`
226/// to omit the field.
227///
228/// tix's vocabulary has no "critical", so `high` is `1` rather than `0` — claiming
229/// urgency tix never expressed would be a guess. An explicitly unset priority
230/// (`priority: ''`) omits the field; any other free-form value is exported as `2`
231/// (medium) and reported.
232#[must_use]
233pub fn priority_for(priority: &Priority) -> (Option<u8>, Option<String>) {
234    match priority {
235        Priority::Low => (Some(3), None),
236        Priority::Medium => (Some(2), None),
237        Priority::High => (Some(1), None),
238        Priority::Other(raw) if raw.trim().is_empty() => (None, None),
239        Priority::Other(raw) => (
240            Some(2),
241            Some(format!(
242                "priority `{raw}` has no beads band; exported as 2 (medium)"
243            )),
244        ),
245    }
246}
247
248/// Map a tix `type:` onto a beads `issue_type`. Unknown values fall back to `task`
249/// (beads validates the field against its own vocabulary) and are reported.
250#[must_use]
251pub fn issue_type_for(issue: &Issue) -> (String, Option<String>) {
252    match issue.type_.as_deref() {
253        None => ("task".to_string(), None),
254        Some(t) if BEADS_TYPES.contains(&t) => (t.to_string(), None),
255        Some(t) => (
256            "task".to_string(),
257            Some(format!(
258                "type `{t}` is not a beads issue_type; exported as `task`"
259            )),
260        ),
261    }
262}
263
264/// Every local field consumed by [`plan`], so a reader can see the whole mapping in
265/// one place. Anything else on the issue is local bookkeeping with no remote
266/// meaning and is not reported: `filename`, `history`, `created`/`updated`,
267/// `gitlab_id`/`linear_id`, and the `commit` an issue records when it is moved to a
268/// terminal column (a git SHA says nothing in a Dolt database, and it stays on the
269/// local issue).
270const UNMAPPED_FIELDS: [&str; 15] = [
271    "estimate",
272    "mission",
273    "task",
274    "related_issues",
275    "series",
276    "health",
277    "files",
278    "cycles",
279    "milestones",
280    "projects",
281    "epics",
282    "reviewer",
283    "start_date",
284    "due_date",
285    "time_entries",
286];
287
288/// Whether a field named in [`UNMAPPED_FIELDS`] carries anything worth reporting.
289fn field_is_set(issue: &Issue, field: &str) -> bool {
290    match field {
291        "estimate" => issue.estimate.is_some(),
292        "mission" => issue.mission.is_some(),
293        "task" => issue.task.is_some(),
294        "related_issues" => !issue.related_issues.is_empty(),
295        "series" => issue.series.is_some(),
296        "health" => issue.health.is_some(),
297        "files" => !issue.files.is_empty(),
298        "cycles" => issue.cycles.as_ref().is_some_and(|v| !v.is_empty()),
299        "milestones" => issue.milestones.as_ref().is_some_and(|v| !v.is_empty()),
300        "projects" => issue.projects.as_ref().is_some_and(|v| !v.is_empty()),
301        "epics" => issue.epics.as_ref().is_some_and(|v| !v.is_empty()),
302        "reviewer" => issue.reviewer.is_some(),
303        "start_date" => issue.start_date.is_some(),
304        "due_date" => issue.due_date.is_some(),
305        "time_entries" => !issue.time_entries.is_empty(),
306        _ => false,
307    }
308}
309
310/// The diagnostics for the local data beads cannot hold on one issue.
311fn unmapped_diagnostics(issue: &Issue) -> Vec<BeadsDiagnostic> {
312    let subject = format!("#{} {}", issue.number, issue.title);
313    let mut out = Vec::new();
314    for field in UNMAPPED_FIELDS {
315        if field_is_set(issue, field) {
316            out.push(BeadsDiagnostic {
317                number: Some(issue.number),
318                subject: subject.clone(),
319                field: field.to_string(),
320                message: "beads has no column for this; the local value is kept as-is".to_string(),
321            });
322        }
323    }
324    out
325}
326
327/// Which issue's row carries an edge's record, and which issue it names.
328///
329/// beads stores a dependency on the **dependent**: `blocks` lives on the blocked
330/// side, `duplicates` on the duplicate, `parent-child` on the child. `related` is
331/// symmetric, so its stored `(min, max)` order picks the carrier.
332fn edge_endpoints(rel: &Relationship) -> (crate::id::IssueRef, crate::id::IssueRef) {
333    let canonical = rel.canonical();
334    match canonical.kind {
335        RelationKind::Blocks => (canonical.to, canonical.from),
336        RelationKind::Duplicates => (canonical.from, canonical.to),
337        _ => (canonical.from, canonical.to),
338    }
339}
340
341/// The beads relation name for a tix relation kind.
342fn relation_name(kind: RelationKind) -> &'static str {
343    match kind {
344        RelationKind::Blocks => "blocks",
345        RelationKind::Duplicates => "duplicates",
346        _ => "relates-to",
347    }
348}
349
350/// The dependencies to hang off each row, keyed by the tix issue number of the row,
351/// plus the diagnostics for edges that point outside the export set.
352fn dependencies_by_row(
353    relationships: &[Relationship],
354    prefix: &str,
355    aliases: &crate::id::AliasIndex,
356    exported: &HashSet<u32>,
357) -> (BTreeMap<u32, Vec<BeadsDependency>>, Vec<BeadsDiagnostic>) {
358    let mut by_row: BTreeMap<u32, Vec<BeadsDependency>> = BTreeMap::new();
359    let mut diagnostics = Vec::new();
360    let mut seen: HashSet<&str> = HashSet::new();
361    for rel in relationships {
362        // `relationships_for` is an adjacency lookup, so an edge arrives from both
363        // endpoints; the stable id is the dedup key.
364        if !seen.insert(rel.id.as_str()) {
365            continue;
366        }
367        let (row_ref, other_ref) = edge_endpoints(rel);
368        let resolve_number = |reference: crate::id::IssueRef| {
369            reference.as_number().or_else(|| {
370                aliases
371                    .resolve(&reference)
372                    .ok()
373                    .and_then(|resolved| resolved.as_number())
374            })
375        };
376        let (Some(row), Some(other)) = (resolve_number(row_ref), resolve_number(other_ref)) else {
377            diagnostics.push(BeadsDiagnostic {
378                number: None,
379                subject: format!("{row_ref} -> {other_ref} ({})", rel.id),
380                field: "relationships".to_string(),
381                message: "an endpoint does not resolve to an exported issue".to_string(),
382            });
383            continue;
384        };
385        if !exported.contains(&row) || !exported.contains(&other) {
386            let missing = if exported.contains(&row) { other } else { row };
387            diagnostics.push(BeadsDiagnostic {
388                number: Some(row),
389                subject: format!("#{row} -> #{other} ({})", rel.id),
390                field: "relationships".to_string(),
391                message: format!(
392                    "target #{missing} is not in the export set, so beads would skip this \
393                     edge; export it too or the relation stays local"
394                ),
395            });
396            continue;
397        }
398        by_row.entry(row).or_default().push(BeadsDependency {
399            issue_id: beads_id(prefix, row),
400            depends_on_id: beads_id(prefix, other),
401            kind: relation_name(rel.canonical().kind).to_string(),
402        });
403    }
404    (by_row, diagnostics)
405}
406
407/// The rows to hand to `bd`, in issue-number order, and everything local that
408/// could not go with them.
409#[derive(Debug, Clone, Default, PartialEq, Eq)]
410pub struct BeadsPlan {
411    pub rows: Vec<BeadsRow>,
412    pub diagnostics: Vec<BeadsDiagnostic>,
413}
414
415/// Build the `bd import` rows for a set of issues.
416///
417/// A *parent* becomes a `parent-child` dependency on the child's own row (beads
418/// stores no `parent` field), and every typed edge lands per the module's table.
419#[must_use]
420pub fn plan(
421    prefix: &str,
422    resolution: &CategoryResolution,
423    issues: &[Issue],
424    relationships: &[Relationship],
425) -> BeadsPlan {
426    let exported: HashSet<u32> = issues.iter().map(|i| i.number).collect();
427    let aliases = crate::id::AliasIndex::build(issues);
428    let (mut deps_by_row, mut diagnostics) =
429        dependencies_by_row(relationships, prefix, &aliases, &exported);
430
431    let mut ordered: Vec<&Issue> = issues.iter().collect();
432    ordered.sort_by_key(|i| i.number);
433
434    let mut rows = Vec::with_capacity(ordered.len());
435    for issue in ordered {
436        let (priority, priority_note) = priority_for(&issue.priority);
437        let (issue_type, type_note) = issue_type_for(issue);
438
439        let mut deps = deps_by_row.remove(&issue.number).unwrap_or_default();
440        if let Some(parent_ref) = issue.parent {
441            let parent = aliases
442                .resolve(&parent_ref)
443                .ok()
444                .and_then(|resolved| resolved.as_number())
445                .or_else(|| parent_ref.as_number());
446            if let Some(parent) = parent {
447                if exported.contains(&parent) {
448                    deps.push(BeadsDependency {
449                        issue_id: beads_id(prefix, issue.number),
450                        depends_on_id: beads_id(prefix, parent),
451                        kind: "parent-child".to_string(),
452                    });
453                } else {
454                    diagnostics.push(BeadsDiagnostic {
455                        number: Some(issue.number),
456                        subject: format!("#{} {}", issue.number, issue.title),
457                        field: "parent".to_string(),
458                        message: format!(
459                            "parent #{parent} is not in the export set; beads would skip the \
460                             parent-child edge"
461                        ),
462                    });
463                }
464            } else {
465                diagnostics.push(BeadsDiagnostic {
466                    number: Some(issue.number),
467                    subject: format!("#{} {}", issue.number, issue.title),
468                    field: "parent".to_string(),
469                    message: format!("parent {parent_ref} does not resolve to an exported issue"),
470                });
471            }
472        }
473        deps.sort_by(|a, b| (&a.kind, &a.depends_on_id).cmp(&(&b.kind, &b.depends_on_id)));
474
475        let subject = format!("#{} {}", issue.number, issue.title);
476        for note in [priority_note, type_note].into_iter().flatten() {
477            diagnostics.push(BeadsDiagnostic {
478                number: Some(issue.number),
479                subject: subject.clone(),
480                field: "type/priority".to_string(),
481                message: note,
482            });
483        }
484        diagnostics.extend(unmapped_diagnostics(issue));
485
486        rows.push(BeadsRow {
487            record_type: issue_record_type(),
488            id: beads_id(prefix, issue.number),
489            title: issue.title.clone(),
490            description: (!issue.body.trim().is_empty()).then(|| issue.body.clone()),
491            status: Some(status_for(issue, resolution).to_string()),
492            priority,
493            issue_type: Some(issue_type),
494            assignee: issue.assignee.clone(),
495            owner: None,
496            labels: issue.labels.clone(),
497            dependencies: deps,
498            close_reason: close_reason_for(issue, resolution),
499            created_at: None,
500            updated_at: None,
501        });
502    }
503    BeadsPlan { rows, diagnostics }
504}
505
506// ---------------------------------------------------------------------------
507// JSONL
508// ---------------------------------------------------------------------------
509
510/// One JSON object per line, `bd export`'s own encoding and `bd import`'s accepted
511/// input. Trailing newline included; an empty plan is an empty string.
512pub fn to_jsonl(rows: &[BeadsRow]) -> Result<String> {
513    let mut out = String::new();
514    for row in rows {
515        let line = serde_json::to_string(row)
516            .map_err(|e| TixError::InvalidInput(format!("could not encode a beads row: {e}")))?;
517        writeln!(out, "{line}")
518            .map_err(|e| TixError::InvalidInput(format!("could not encode a beads row: {e}")))?;
519    }
520    Ok(out)
521}
522
523/// Read rows back from `bd export` (JSONL) or `bd list --json` (an array),
524/// unwrapping the `BD_JSON_ENVELOPE` `{"data": …}` when present.
525///
526/// Non-JSON lines are skipped rather than failing the read: `bd` prints a
527/// one-time usage notice on stdout, and a caller that asked for JSON should get
528/// the rows it asked for rather than an error about a banner.
529pub fn parse_rows(raw: &str) -> Result<Vec<BeadsRow>> {
530    let trimmed = raw.trim();
531    if trimmed.is_empty() {
532        return Ok(Vec::new());
533    }
534    if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
535        let body = value.get("data").unwrap_or(&value);
536        if body.is_array() {
537            return serde_json::from_value(body.clone()).map_err(|e| {
538                TixError::InvalidInput(format!("could not read bd JSON output: {e}"))
539            });
540        }
541        if let Ok(row) = serde_json::from_value::<BeadsRow>(body.clone()) {
542            return Ok(vec![row]);
543        }
544    }
545    let mut rows = Vec::new();
546    for line in trimmed.lines() {
547        let line = line.trim();
548        if line.is_empty() || !line.starts_with('{') {
549            continue;
550        }
551        if let Ok(row) = serde_json::from_str::<BeadsRow>(line) {
552            rows.push(row);
553        }
554    }
555    Ok(rows)
556}
557
558// ---------------------------------------------------------------------------
559// Push
560// ---------------------------------------------------------------------------
561
562/// Write the rows to `bd` via `bd import <file>`.
563///
564/// A file rather than stdout, because [`CommandRunner`] spawns without a stdin
565/// pipe; `bd import` reads a path and a `-` alike, and the path is what the
566/// runner can carry. Returns `bd`'s stdout so the caller can surface its
567/// "Imported N issue(s)" line.
568pub fn push(runner: &dyn CommandRunner, binary: &str, rows: &[BeadsRow]) -> Result<String> {
569    let jsonl = to_jsonl(rows)?;
570    let path = std::env::temp_dir().join(format!(
571        "tix-beads-{}-{}.jsonl",
572        std::process::id(),
573        std::time::SystemTime::now()
574            .duration_since(std::time::UNIX_EPOCH)
575            .map_or(0, |d| d.as_nanos())
576    ));
577    std::fs::write(&path, jsonl.as_bytes()).map_err(|e| {
578        TixError::External(format!("could not stage the beads import at {path:?}: {e}"))
579    })?;
580    let args = vec!["import".to_string(), path.display().to_string()];
581    let outcome: Result<CommandOutcome> = runner.run(binary, &args, &[]);
582    let _ = std::fs::remove_file(&path);
583    let outcome = outcome?;
584    if !outcome.success {
585        return Err(TixError::External(format!(
586            "`{binary} import` failed: {}",
587            outcome.text().trim()
588        )));
589    }
590    Ok(outcome.stdout)
591}
592
593// ---------------------------------------------------------------------------
594// The `bd` CLI service
595// ---------------------------------------------------------------------------
596
597/// The environment every `bd` call runs with.
598///
599/// A configured workspace is exported as `BEADS_DIR` — beads' own override for
600/// "which workspace" — rather than passed as a flag, because it is the one knob
601/// `bd export`, `bd import` and `bd update` all honour.
602#[must_use]
603pub fn env_for(config: &Config) -> Vec<(String, String)> {
604    config
605        .beads
606        .workspace
607        .as_ref()
608        .map(|dir| vec![("BEADS_DIR".to_string(), dir.display().to_string())])
609        .unwrap_or_default()
610}
611
612/// Read every issue beads holds, through `bd export`.
613///
614/// `bd export` writes JSONL on stdout and excludes infrastructure beads (agents,
615/// roles, messages) by default, which is what a view of the *work* wants.
616pub fn fetch(runner: &dyn CommandRunner, config: &Config) -> Result<Vec<BeadsRow>> {
617    let outcome = runner.run(
618        &config.beads.binary,
619        &["export".to_string()],
620        &env_for(config),
621    )?;
622    if !outcome.success {
623        return Err(TixError::External(format!(
624            "`{} export` failed: {}",
625            config.beads.binary,
626            outcome.text().trim()
627        )));
628    }
629    parse_rows(&outcome.stdout)
630}
631
632/// What a probe of `bd` and its workspace found.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct Detection {
635    /// The binary that was probed.
636    pub binary: String,
637    /// `bd version` answered.
638    pub available: bool,
639    /// `bd where` resolved an active workspace.
640    pub workspace: bool,
641    /// The version `bd version --json` reported.
642    pub version: Option<String>,
643    /// The resolved workspace path.
644    pub path: Option<String>,
645    /// The events-journal setting, when it was readable. A disabled journal makes
646    /// a live mirror silently empty, so it is worth reporting.
647    pub journal: Option<bool>,
648    /// One line a human can act on.
649    pub detail: String,
650}
651
652/// Probe for `bd` and its workspace. **Read-only**: it runs only `bd version`,
653/// `bd where` and `bd config get`, never opens the database, and writes nothing.
654#[must_use]
655pub fn detect(runner: &dyn CommandRunner, config: &Config) -> Detection {
656    let binary = config.beads.binary.clone();
657    let env = env_for(config);
658    let mut detection = Detection {
659        binary: binary.clone(),
660        available: false,
661        workspace: false,
662        version: None,
663        path: None,
664        journal: None,
665        detail: String::new(),
666    };
667
668    match runner.run(
669        &binary,
670        &["version".to_string(), "--json".to_string()],
671        &env,
672    ) {
673        Ok(outcome) if outcome.success => {
674            detection.available = true;
675            detection.version = serde_json::from_str::<serde_json::Value>(&outcome.stdout)
676                .ok()
677                .and_then(|v| {
678                    v.get("version")
679                        .and_then(|s| s.as_str())
680                        .map(str::to_string)
681                });
682        }
683        _ => {
684            detection.detail =
685                format!("`{binary}` is not on PATH — the beads integration stays off");
686            return detection;
687        }
688    }
689
690    if let Ok(outcome) = runner.run(&binary, &["where".to_string(), "--json".to_string()], &env) {
691        if outcome.success {
692            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&outcome.stdout) {
693                detection.path = value
694                    .get("path")
695                    .and_then(|s| s.as_str())
696                    .map(str::to_string);
697                detection.workspace = detection.path.is_some();
698            }
699        }
700    }
701
702    if detection.workspace {
703        if let Ok(outcome) = runner.run(
704            &binary,
705            &[
706                "config".to_string(),
707                "get".to_string(),
708                "events-journal".to_string(),
709            ],
710            &env,
711        ) {
712            detection.journal = match outcome.stdout.trim() {
713                "true" => Some(true),
714                "false" => Some(false),
715                _ => None,
716            };
717        }
718        let where_ = detection.path.as_deref().unwrap_or("somewhere");
719        detection.detail = match (detection.version.as_deref(), detection.journal) {
720            (Some(version), Some(false)) => format!(
721                "beads {version} at {where_} — its events journal is OFF, so a live mirror \
722                 would see nothing (enable it wherever beads writes: `bd config set \
723                 events-journal true`)"
724            ),
725            (Some(version), _) => format!("beads {version} at {where_}"),
726            (None, _) => format!("a beads workspace at {where_}"),
727        };
728    } else {
729        detection.detail = format!(
730            "`{binary}` is installed ({}) but no beads workspace is active here",
731            detection.version.as_deref().unwrap_or("?")
732        );
733    }
734    detection
735}
736
737/// The tix issue number one of *our* exported ids names (`<prefix>-<n>`).
738#[must_use]
739pub fn number_from_id(id: &str, prefix: &str) -> Option<u32> {
740    id.strip_prefix(prefix)?.strip_prefix('-')?.parse().ok()
741}
742
743/// The tix issue number an `external_ref` names, when it carries ours (`tix#<n>`).
744#[must_use]
745pub fn number_from_external_ref(reference: &str) -> Option<u32> {
746    reference.strip_prefix("tix#")?.parse().ok()
747}
748
749/// The `external_ref` tix writes back onto a bead it imported, so a later import
750/// recognises it without depending on local sync state.
751#[must_use]
752pub fn external_ref_for(number: u32) -> String {
753    format!("tix#{number}")
754}
755
756/// The local column a beads status maps onto, or `None` when no column does.
757///
758/// The inverse of [`status_for`], and it asks the **category** service the same
759/// way: `closed` lands on the board's completion column, `in_progress` on the
760/// first `active` one, and anything else on the board's first column. A
761/// `close_reason` of `canceled` lands on a `canceled` column when the board has
762/// one — the case [`status_for`] wrote the reason for in the first place.
763#[must_use]
764pub fn column_for_status(
765    resolution: &CategoryResolution,
766    status: &str,
767    close_reason: Option<&str>,
768) -> Option<String> {
769    let column_with = |category: WorkflowCategory| {
770        resolution
771            .columns()
772            .find(|c| resolution.resolve(c) == Some(category))
773            .map(str::to_string)
774    };
775    if close_reason == Some("canceled") {
776        if let Some(column) = column_with(WorkflowCategory::Canceled) {
777            return Some(column);
778        }
779    }
780    match status {
781        "closed" => column_with(WorkflowCategory::Completed),
782        "in_progress" => column_with(WorkflowCategory::Active),
783        _ => resolution.columns().next().map(str::to_string),
784    }
785}
786
787/// beads' `0..4` band maps onto tix's priority vocabulary: `0`/`1` are what tix
788/// calls high, `2` medium, and `3`/`4` low.
789#[must_use]
790pub fn priority_from_beads(priority: Option<u8>) -> Priority {
791    match priority {
792        Some(0 | 1) => Priority::High,
793        Some(3 | 4) => Priority::Low,
794        _ => Priority::Medium,
795    }
796}
797
798/// One row reduced to the local fields it can fill.
799#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct MappedRow {
801    /// The tix issue the row names, when its id, `external_ref`, or the local
802    /// sync state identifies one.
803    pub number: Option<u32>,
804    pub title: String,
805    pub body: String,
806    /// The local column, or `None` when the board has no column for the status.
807    pub status: Option<String>,
808    pub priority: Priority,
809    pub type_: Option<String>,
810    pub labels: Vec<String>,
811    pub assignee: Option<String>,
812    /// The bead's id, which is what identity is keyed on.
813    pub beads_id: String,
814    /// The bead's `updated_at`, for the newer-wins conflict rule.
815    pub updated_at: Option<String>,
816}
817
818/// Map one row onto the local fields it can fill. Pure: no store, no subprocess.
819///
820/// `known` is the local sync state's id → number map, consulted for
821/// beads-originated issues whose ids mean nothing to tix.
822#[must_use]
823pub fn map_row(
824    row: &BeadsRow,
825    config: &Config,
826    resolution: &CategoryResolution,
827    known: &BTreeMap<String, u32>,
828) -> MappedRow {
829    let prefix = &config.beads.id_prefix;
830    let number = number_from_id(&row.id, prefix)
831        .or_else(|| known.get(&row.id).copied())
832        .or_else(|| {
833            row.owner
834                .as_deref()
835                .and_then(number_from_external_ref)
836                .or_else(|| number_from_external_ref(&row.id))
837        });
838    let accepted_types: Vec<&str> = match config.types.as_deref() {
839        Some(types) => types.iter().map(String::as_str).collect(),
840        None => crate::config::DEFAULT_TYPES.to_vec(),
841    };
842    let type_ = row
843        .issue_type
844        .as_deref()
845        .filter(|t| accepted_types.contains(t))
846        .map(str::to_string);
847    MappedRow {
848        number,
849        title: row.title.clone(),
850        body: row.description.clone().unwrap_or_default(),
851        status: column_for_status(
852            resolution,
853            row.status.as_deref().unwrap_or("open"),
854            row.close_reason.as_deref(),
855        ),
856        priority: priority_from_beads(row.priority),
857        type_,
858        labels: row.labels.clone(),
859        assignee: row.effective_assignee().map(str::to_string),
860        beads_id: row.id.clone(),
861        updated_at: row.updated_at.clone(),
862    }
863}
864
865/// The identity map tix keeps between a beads workspace and its store.
866///
867/// Only beads-originated issues need an entry: a bead tix exported has a
868/// deterministic `<prefix>-<number>` id, which is its own mapping. The file is
869/// small, and it is **identity** rather than cache — losing it would make the
870/// next import create a second tix issue for a bead that already has one — so it
871/// is written to the project root and is safe to commit.
872#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
873pub struct State {
874    /// beads id → tix issue number, for beads-originated issues.
875    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
876    pub numbers: BTreeMap<String, u32>,
877    /// beads id → the `updated_at` last seen, the watermark that stops the two
878    /// pipes echoing each other's change back.
879    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
880    pub synced_at: BTreeMap<String, String>,
881    /// beads id → [`fingerprint`] of the row last **exported** for it.
882    ///
883    /// This is the outbound watermark: a row that fingerprints the same as the
884    /// one already sent has not changed, so a re-export can skip it. It is what
885    /// makes a second export cheap on a repository with thousands of issues.
886    ///
887    /// Only meaningful together with [`Self::workspace`]: a fingerprint records
888    /// that a row was exported **somewhere**, and skipping is only correct when
889    /// "somewhere" is still the workspace in front of us.
890    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
891    pub exported: BTreeMap<String, u64>,
892    /// The workspace [`Self::exported`] was written against.
893    ///
894    /// `None` in a state file written before this was recorded, which is itself a
895    /// reason not to trust the watermark: the export compares it and resets when it
896    /// differs, so pointing tix at a different workspace re-exports everything
897    /// instead of silently sending nothing.
898    #[serde(default, skip_serializing_if = "Option::is_none")]
899    pub workspace: Option<String>,
900}
901
902/// The beads workspace an export is about to talk to, resolved as cheaply as it
903/// can be.
904///
905/// The precedence is the one `bd` itself uses — `BEADS_DIR`, then the configured
906/// workspace, then the nearest `.beads` walking up from the working directory —
907/// and it is resolved **without a subprocess**, because it is checked on every
908/// export and a repeat export is a few hundred milliseconds.
909///
910/// It exists to make [`State::exported`] safe. A fingerprint says "this row has
911/// been exported"; that is only true of the workspace it was exported *to*, so
912/// the two are recorded together and compared on every run.
913#[must_use]
914pub fn workspace_identity(config: &Config) -> Option<String> {
915    let cwd = std::env::current_dir().ok()?;
916    workspace_identity_from(
917        std::env::var("BEADS_DIR").ok().as_deref(),
918        config.beads.workspace.as_deref(),
919        &cwd,
920    )
921}
922
923/// [`workspace_identity`] with its three inputs passed in.
924///
925/// Split out so the precedence can be tested without a process-global
926/// environment, which every other test in the suite shares.
927fn workspace_identity_from(
928    beads_dir: Option<&str>,
929    configured: Option<&std::path::Path>,
930    cwd: &std::path::Path,
931) -> Option<String> {
932    let canonical = |path: &std::path::Path| {
933        std::fs::canonicalize(path)
934            .unwrap_or_else(|_| path.to_path_buf())
935            .display()
936            .to_string()
937    };
938    if let Some(dir) = beads_dir {
939        if !dir.trim().is_empty() {
940            return Some(canonical(std::path::Path::new(dir)));
941        }
942    }
943    if let Some(dir) = configured {
944        return Some(canonical(dir));
945    }
946    let mut current = cwd.to_path_buf();
947    loop {
948        let candidate = current.join(".beads");
949        if candidate.is_dir() {
950            return Some(canonical(&candidate));
951        }
952        if !current.pop() {
953            return None;
954        }
955    }
956}
957
958/// A stable 64-bit FNV-1a fingerprint of a row's serialized form.
959///
960/// **Stable on purpose.** `DefaultHasher`'s algorithm is explicitly not
961/// guaranteed to stay the same across Rust releases, and a fingerprint that
962/// changed when the toolchain did would make every issue look modified and
963/// re-export the whole board for no reason. FNV-1a is fixed by its constants, so
964/// an unchanged row fingerprints the same forever.
965///
966/// The whole row is hashed — including its dependency records — so an edge
967/// added, re-kinded or dropped changes the fingerprint of the issue that carries
968/// it, which is the one that has to be re-sent.
969pub fn fingerprint(row: &BeadsRow) -> Result<u64> {
970    let text = serde_json::to_string(row)
971        .map_err(|e| TixError::InvalidInput(format!("could not fingerprint a beads row: {e}")))?;
972    Ok(fnv1a(text.as_bytes()))
973}
974
975/// FNV-1a, 64-bit.
976fn fnv1a(bytes: &[u8]) -> u64 {
977    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
978    const PRIME: u64 = 0x0000_0100_0000_01b3;
979    let mut hash = OFFSET_BASIS;
980    for byte in bytes {
981        hash ^= u64::from(*byte);
982        hash = hash.wrapping_mul(PRIME);
983    }
984    hash
985}
986
987/// The rows an export should send, the fingerprints to record for them, and how
988/// many rows were already up to date.
989#[derive(Debug, Clone, Default, PartialEq, Eq)]
990pub struct BeadsPayload {
991    /// The rows to send, in plan order.
992    pub rows: Vec<BeadsRow>,
993    /// `(beads id, fingerprint)` for each row in [`Self::rows`], to be recorded
994    /// once the push succeeds.
995    pub fingerprints: Vec<(String, u64)>,
996    /// How many rows matched the watermark and were left out.
997    pub unchanged: usize,
998}
999
1000/// Choose the rows to send, given the export watermark.
1001///
1002/// Pure, and separate from the push so the incremental rule can be tested without
1003/// a store or a `bd`. Rows are taken by value so the payload moves rather than
1004/// clones them — on a first run this is the whole board.
1005pub fn select_payload(
1006    rows: Vec<BeadsRow>,
1007    exported: &BTreeMap<String, u64>,
1008    all: bool,
1009) -> Result<BeadsPayload> {
1010    let mut payload = BeadsPayload::default();
1011    for row in rows {
1012        let fingerprint = fingerprint(&row)?;
1013        if all || exported.get(&row.id).copied() != Some(fingerprint) {
1014            payload.fingerprints.push((row.id.clone(), fingerprint));
1015            payload.rows.push(row);
1016        } else {
1017            payload.unchanged += 1;
1018        }
1019    }
1020    Ok(payload)
1021}
1022
1023/// The state file's name, at the project root.
1024pub const STATE_FILE: &str = ".tix-beads-state.json";
1025
1026/// The state file's path for a project root.
1027#[must_use]
1028pub fn state_path(root: &Path) -> PathBuf {
1029    root.join(STATE_FILE)
1030}
1031
1032/// Read the state, or the empty state when there is none (and when the file is
1033/// unreadable or malformed, which is reported by the caller's first write rather
1034/// than failing every read).
1035#[must_use]
1036pub fn load_state(root: &Path) -> State {
1037    std::fs::read_to_string(state_path(root))
1038        .ok()
1039        .and_then(|raw| serde_json::from_str(&raw).ok())
1040        .unwrap_or_default()
1041}
1042
1043/// Write the state atomically-enough for a single-writer CLI: a temp file beside
1044/// the target, then a rename.
1045pub fn save_state(root: &Path, state: &State) -> Result<()> {
1046    let path = state_path(root);
1047    let body = serde_json::to_string_pretty(state)
1048        .map_err(|e| TixError::InvalidInput(format!("could not encode the beads state: {e}")))?;
1049    let tmp = path.with_extension("json.tmp");
1050    std::fs::write(&tmp, body.as_bytes())?;
1051    std::fs::rename(&tmp, &path)?;
1052    Ok(())
1053}
1054
1055/// One import pass's outcome.
1056#[derive(Debug, Clone, Default, PartialEq, Eq)]
1057pub struct ImportReport {
1058    pub created: usize,
1059    pub updated: usize,
1060    pub skipped: usize,
1061    /// How many of `created` were beads-originated issues whose id ⇄ number
1062    /// pairing had to be recorded, because their ids are not tix's own. Reported
1063    /// as a count rather than a diagnostic: nothing is unrepresentable here, it is
1064    /// just identity that had to be written down.
1065    pub foreign: usize,
1066    pub diagnostics: Vec<BeadsDiagnostic>,
1067}
1068
1069/// A blank issue to fill, so the import path and the tests share one literal.
1070fn blank_issue() -> Issue {
1071    Issue {
1072        number: 0,
1073        id: None,
1074        title: String::new(),
1075        status: crate::model::Status::new(""),
1076        labels: Vec::new(),
1077        type_: None,
1078        priority: Priority::Medium,
1079        assignee: None,
1080        estimate: None,
1081        commit: None,
1082        gitlab_id: None,
1083        linear_id: None,
1084        mission: None,
1085        task: None,
1086        relationships: Vec::new(),
1087        related_issues: Vec::new(),
1088        files: Vec::new(),
1089        cycles: None,
1090        milestones: None,
1091        reviewer: None,
1092        start_date: None,
1093        due_date: None,
1094        created: crate::model::today(),
1095        updated: crate::model::today(),
1096        filename: String::new(),
1097        body: String::new(),
1098        history: crate::model::History::new(),
1099        has_retired_links_key: false,
1100        time_entries: Vec::new(),
1101        parent: None,
1102        series: None,
1103        health: None,
1104        projects: None,
1105        epics: None,
1106        extra_frontmatter: std::collections::BTreeMap::new(),
1107        archived: false,
1108        entered_at: None,
1109        source: None,
1110    }
1111}
1112
1113/// Pull beads rows into the local store.
1114///
1115/// Each row is its own transaction, exactly as the GitLab/Linear import does it:
1116/// one remote issue's failure must not roll back the others. A row that names an
1117/// existing issue **overwrites it only when the bead is newer** ([`crate::sync::is_remote_newer`]),
1118/// which is the same conflict rule the other trackers use; an older bead is left
1119/// alone and counted as skipped.
1120///
1121/// The returned [`ImportReport`] names what happened, and `state` is updated in
1122/// memory — the caller saves it, so a read-only path never writes.
1123pub fn import_into(
1124    store: &Store,
1125    config: &Config,
1126    resolution: &CategoryResolution,
1127    rows: &[BeadsRow],
1128    state: &mut State,
1129) -> Result<ImportReport> {
1130    let by_number: BTreeMap<u32, Issue> = store
1131        .list()?
1132        .into_iter()
1133        .map(|issue| (issue.number, issue))
1134        .collect();
1135
1136    let mut report = ImportReport::default();
1137    for row in rows {
1138        let mapped = map_row(row, config, resolution, &state.numbers);
1139        let Some(status) = mapped.status.clone() else {
1140            report.skipped += 1;
1141            report.diagnostics.push(BeadsDiagnostic {
1142                number: mapped.number,
1143                subject: format!("beads {} \"{}\"", row.id, row.title),
1144                field: "status".to_string(),
1145                message: format!(
1146                    "`{}` is not a column on the board; the issue was not imported",
1147                    row.status.as_deref().unwrap_or("open")
1148                ),
1149            });
1150            continue;
1151        };
1152
1153        match mapped.number.and_then(|n| by_number.get(&n).cloned()) {
1154            Some(existing) => {
1155                let newer = match (&mapped.updated_at, existing.updated.as_str()) {
1156                    (Some(remote), local) => crate::sync::is_remote_newer(remote, local),
1157                    (None, _) => true,
1158                };
1159                if !newer {
1160                    report.skipped += 1;
1161                    continue;
1162                }
1163                let mut issue = existing;
1164                issue.title = mapped.title.clone();
1165                issue.body = mapped.body.clone();
1166                issue.status = crate::model::Status::new(status);
1167                issue.priority = mapped.priority.clone();
1168                issue.labels = mapped.labels.clone();
1169                issue.assignee = mapped.assignee.clone();
1170                issue.bump_updated();
1171                store.transaction(|| store.update(&issue.filename, &issue))?;
1172                report.updated += 1;
1173            }
1174            None => {
1175                let mut issue = blank_issue();
1176                issue.title = mapped.title.clone();
1177                issue.status = crate::model::Status::new(status);
1178                issue.priority = mapped.priority.clone();
1179                issue.labels = mapped.labels.clone();
1180                issue.assignee = mapped.assignee.clone();
1181                issue.type_ = mapped.type_.clone();
1182                let created = store.transaction(|| store.create(&mut issue, &mapped.body, None))?;
1183                let _ = created;
1184                report.created += 1;
1185                if number_from_id(&row.id, &config.beads.id_prefix).is_none() {
1186                    // A bead tix did not author: remember the pairing, so the next
1187                    // import recognises it instead of creating a second issue.
1188                    state.numbers.insert(row.id.clone(), issue.number);
1189                    report.foreign += 1;
1190                }
1191            }
1192        }
1193        if let Some(updated) = &mapped.updated_at {
1194            state.synced_at.insert(row.id.clone(), updated.clone());
1195        }
1196    }
1197    Ok(report)
1198}
1199
1200/// Install the `beads → tix` pipe: executable `.beads/hooks/{on_create,on_update,on_close}`
1201/// scripts that run `tix beads pull <id>`.
1202///
1203/// beads calls a hook as `<script> <issue-id> <event>` with the issue JSON on
1204/// **stdin**, fire-and-forget, and silently skips a script that is not
1205/// executable — so the mode bit is set here and the scripts always exit 0. The
1206/// project root and the `tix` binary are baked in, because beads does not run
1207/// hooks in this directory and does not guarantee `tix` is on ITS path.
1208pub fn install_hooks(root: &Path, beads_dir: &Path, binary: &str) -> Result<Vec<PathBuf>> {
1209    let hooks_dir = beads_dir.join("hooks");
1210    std::fs::create_dir_all(&hooks_dir)?;
1211    let exe = std::env::current_exe()
1212        .ok()
1213        .filter(|p| p.exists())
1214        .map_or_else(|| "tix".to_string(), |p| p.display().to_string());
1215    let root = root.display();
1216    let mut written = Vec::new();
1217    for hook in ["on_create", "on_update", "on_close"] {
1218        let path = hooks_dir.join(hook);
1219        let body = format!(
1220            "#!/bin/sh\n\
1221             # The beads -> tix pipe, installed by `tix init --beads sync`.\n\
1222             # beads calls this as `{hook} <issue-id> <event>` with the issue JSON on\n\
1223             # stdin, fire-and-forget, and skips it silently unless it is executable.\n\
1224             # `tix beads pull` is idempotent and never writes to beads, so a missed\n\
1225             # run is repaired by the next one.\n\
1226             cd '{root}' 2>/dev/null || exit 0\n\
1227             '{exe}' beads pull --quiet >/dev/null 2>&1\n\
1228             exit 0\n"
1229        );
1230        crate::commands::atomic_write(&path, &body)?;
1231        make_executable(&path)?;
1232        written.push(path);
1233    }
1234    let _ = binary;
1235    Ok(written)
1236}
1237
1238#[cfg(unix)]
1239fn make_executable(path: &Path) -> Result<()> {
1240    use std::os::unix::fs::PermissionsExt as _;
1241    let mode = std::fs::metadata(path)?.permissions().mode();
1242    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode | 0o111))?;
1243    Ok(())
1244}
1245
1246#[cfg(not(unix))]
1247fn make_executable(_path: &Path) -> Result<()> {
1248    // beads skips a non-executable hook; on Windows the extension is what counts,
1249    // and a script written to `.beads/hooks/` is not runnable there anyway.
1250    Ok(())
1251}
1252
1253/// One record from the beads events journal.
1254///
1255/// The journal is beads' own binlog: every committed mutation writes one ordered
1256/// record, and a consumer resumes from a `seq` it can checkpoint.
1257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1258pub struct EventRecord {
1259    #[serde(default)]
1260    pub seq: u64,
1261    #[serde(default)]
1262    pub ts: Option<String>,
1263    #[serde(default)]
1264    pub op: String,
1265    #[serde(default)]
1266    pub issue_id: String,
1267    #[serde(default)]
1268    pub actor: Option<String>,
1269    /// The resulting issue state, as the record carries it.
1270    #[serde(default)]
1271    pub issue: Option<BeadsRow>,
1272}
1273
1274/// Read the beads events journal from `since` (inclusive), at most `limit`
1275/// records.
1276///
1277/// `bd events tail` writes JSON Lines in sequence order. It is only populated
1278/// where the journal is enabled — a workspace whose writers never turned it on
1279/// stays permanently empty, and an empty journal is indistinguishable from a
1280/// caught-up one — so a caller that got nothing is told that rather than left to
1281/// assume it is current.
1282pub fn events(
1283    runner: &dyn CommandRunner,
1284    config: &Config,
1285    since: u64,
1286    limit: u64,
1287) -> Result<Vec<EventRecord>> {
1288    let args = vec![
1289        "events".to_string(),
1290        "tail".to_string(),
1291        "--since".to_string(),
1292        since.to_string(),
1293        "--limit".to_string(),
1294        limit.to_string(),
1295    ];
1296    let outcome = runner.run(&config.beads.binary, &args, &env_for(config))?;
1297    if !outcome.success {
1298        return Err(TixError::External(format!(
1299            "`{} events tail` failed: {}",
1300            config.beads.binary,
1301            outcome.text().trim()
1302        )));
1303    }
1304    let trimmed = outcome.stdout.trim();
1305    if trimmed.is_empty() {
1306        return Ok(Vec::new());
1307    }
1308    // The CLI writes JSON Lines; the HTTP endpoint wraps them. Accept both.
1309    if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1310        if let Some(records) = value.get("records") {
1311            if let Ok(parsed) = serde_json::from_value::<Vec<EventRecord>>(records.clone()) {
1312                return Ok(parsed);
1313            }
1314        }
1315    }
1316    Ok(trimmed
1317        .lines()
1318        .filter(|line| line.trim_start().starts_with('{'))
1319        .filter_map(|line| serde_json::from_str::<EventRecord>(line).ok())
1320        .collect())
1321}
1322
1323/// Refuse a write when the configured mode is `mirror`.
1324///
1325/// `mirror` means read-only **by contract**: tix writes neither to beads nor to
1326/// an issue file, so every write path asks here first. `off` is not refused —
1327/// that mode means tix does nothing with beads on its own, and an explicit
1328/// `tix beads push` is the user's direct instruction, not an automatic one.
1329pub fn ensure_writes_allowed(config: &Config) -> Result<()> {
1330    if config.beads.mode == crate::config::BeadsMode::Mirror {
1331        return Err(TixError::Config(
1332            "beads mode is `mirror` (read-only): tix will not write to beads or to an issue \
1333             file. Run `tix init --beads sync` to enable writes."
1334                .to_string(),
1335        ));
1336    }
1337    Ok(())
1338}
1339
1340/// Whether a row is blocked: one of its `blocks` dependencies names an issue that
1341/// is not closed.
1342///
1343/// beads derives its ready frontier this way, so reading it the same way is what
1344/// keeps a mirror agreeing with `bd ready` rather than guessing.
1345#[must_use]
1346pub fn is_blocked(row: &BeadsRow, status_by_id: &BTreeMap<String, String>) -> bool {
1347    row.dependencies
1348        .iter()
1349        .filter(|dep| dep.kind == "blocks")
1350        .any(|dep| {
1351            status_by_id
1352                .get(&dep.depends_on_id)
1353                .is_some_and(|status| status != "closed")
1354        })
1355}
1356
1357// ---------------------------------------------------------------------------
1358// Tests
1359// ---------------------------------------------------------------------------
1360
1361#[cfg(test)]
1362mod tests {
1363    #[allow(clippy::wildcard_imports)]
1364    use super::*;
1365    use crate::config::Config;
1366    use crate::model::Status;
1367    use crate::relationships::RelationKind;
1368    use std::cell::RefCell;
1369
1370    fn issue(number: u32, title: &str) -> Issue {
1371        let mut issue = blank_issue();
1372        issue.number = number;
1373        issue.title = title.to_string();
1374        issue.status = Status::new("todo");
1375        issue
1376    }
1377
1378    fn resolution_for(config: &Config) -> CategoryResolution {
1379        crate::workflow::resolution(config, config.default_config(), "default")
1380    }
1381
1382    #[test]
1383    fn ids_are_deterministic_and_prefixed() {
1384        assert_eq!(beads_id("tix", 1705), "tix-1705");
1385        assert_eq!(beads_id("bd", 7), "bd-7");
1386    }
1387
1388    #[test]
1389    fn status_follows_the_category_service_not_the_column_name() {
1390        let config = Config::default();
1391        let resolution = resolution_for(&config);
1392        // The default board's completion column maps to `completed`.
1393        let done_column =
1394            crate::workflow::completion_column_or_default(config.default_config(), &config);
1395        let mut done = issue(1, "done");
1396        done.status = Status::new(done_column);
1397        assert_eq!(status_for(&done, &resolution), "closed");
1398        assert_eq!(close_reason_for(&done, &resolution), None);
1399
1400        // A column the board does not configure is not complete.
1401        let mut odd = issue(2, "odd");
1402        odd.status = Status::new("not-a-column");
1403        assert_eq!(status_for(&odd, &resolution), "open");
1404    }
1405
1406    #[test]
1407    fn priority_maps_onto_the_beads_band_and_reports_a_stranger() {
1408        assert_eq!(priority_for(&Priority::High), (Some(1), None));
1409        assert_eq!(priority_for(&Priority::Medium), (Some(2), None));
1410        assert_eq!(priority_for(&Priority::Low), (Some(3), None));
1411        // `priority: ''` is unset, not a band.
1412        assert_eq!(priority_for(&Priority::Other(String::new())), (None, None));
1413        let (band, note) = priority_for(&Priority::Other("P0".into()));
1414        assert_eq!(band, Some(2));
1415        assert!(note.unwrap().contains("no beads band"));
1416    }
1417
1418    #[test]
1419    fn type_falls_back_to_task_outside_the_beads_vocabulary() {
1420        let mut i = issue(1, "t");
1421        i.type_ = Some("bug".into());
1422        assert_eq!(issue_type_for(&i), ("bug".to_string(), None));
1423
1424        i.type_ = Some("spike".into());
1425        let (mapped, note) = issue_type_for(&i);
1426        assert_eq!(mapped, "task");
1427        assert!(note.unwrap().contains("not a beads issue_type"));
1428
1429        i.type_ = None;
1430        assert_eq!(issue_type_for(&i), ("task".to_string(), None));
1431    }
1432
1433    #[test]
1434    fn a_blocking_edge_lands_on_the_blocked_row() {
1435        // 1 blocks 2  →  the record lives on 2, naming 1 as the blocker.
1436        let rel = Relationship::new(1, 2, RelationKind::Blocks, "two");
1437        let exported: HashSet<u32> = [1, 2].into_iter().collect();
1438        let (by_row, diags) =
1439            dependencies_by_row(&[rel], "tix", &crate::id::AliasIndex::default(), &exported);
1440        assert_eq!(diags, Vec::<BeadsDiagnostic>::new());
1441        let dep = &by_row[&2][0];
1442        assert_eq!(dep.issue_id, "tix-2");
1443        assert_eq!(dep.depends_on_id, "tix-1");
1444        assert_eq!(dep.kind, "blocks");
1445    }
1446
1447    #[test]
1448    fn duplicates_and_related_use_beads_relation_names() {
1449        let dup = Relationship::new(3, 4, RelationKind::Duplicates, "four");
1450        let rel = Relationship::new(5, 6, RelationKind::Related, "six");
1451        let exported: HashSet<u32> = [3, 4, 5, 6].into_iter().collect();
1452        let (by_row, _) = dependencies_by_row(
1453            &[dup, rel],
1454            "tix",
1455            &crate::id::AliasIndex::default(),
1456            &exported,
1457        );
1458        assert_eq!(by_row[&3][0].kind, "duplicates");
1459        assert_eq!(by_row[&3][0].depends_on_id, "tix-4");
1460        // `related` is symmetric and order-normalized, so the lower number carries it.
1461        assert_eq!(by_row[&5][0].kind, "relates-to");
1462        assert_eq!(by_row[&5][0].depends_on_id, "tix-6");
1463    }
1464
1465    #[test]
1466    fn an_edge_outside_the_export_set_is_reported_not_dropped() {
1467        let rel = Relationship::new(1, 99, RelationKind::Blocks, "gone");
1468        let exported: HashSet<u32> = [1].into_iter().collect();
1469        let (by_row, diags) =
1470            dependencies_by_row(&[rel], "tix", &crate::id::AliasIndex::default(), &exported);
1471        assert!(by_row.is_empty());
1472        assert_eq!(diags.len(), 1);
1473        assert!(diags[0].message.contains("#99"));
1474    }
1475
1476    #[test]
1477    fn a_parent_becomes_a_parent_child_edge_and_local_fields_are_reported() {
1478        let config = Config::default();
1479        let resolution = resolution_for(&config);
1480        let mut child = issue(5, "child");
1481        child.parent = Some(crate::id::IssueRef::Number(4));
1482        child.cycles = Some(vec!["October 2026".into()]);
1483        child.estimate = Some("3d".into());
1484        let plan = plan("tix", &resolution, &[issue(4, "parent"), child], &[]);
1485
1486        let child_row = plan.rows.iter().find(|r| r.id == "tix-5").unwrap();
1487        assert_eq!(child_row.dependencies.len(), 1);
1488        assert_eq!(child_row.dependencies[0].kind, "parent-child");
1489        assert_eq!(child_row.dependencies[0].depends_on_id, "tix-4");
1490
1491        let fields: Vec<&str> = plan.diagnostics.iter().map(|d| d.field.as_str()).collect();
1492        assert!(fields.contains(&"cycles"), "{fields:?}");
1493        assert!(fields.contains(&"estimate"), "{fields:?}");
1494        // The parent is representable, so it is not reported.
1495        assert!(!fields.contains(&"parent"), "{fields:?}");
1496    }
1497
1498    #[test]
1499    fn jsonl_round_trips_through_the_reader() {
1500        let row = BeadsRow {
1501            record_type: issue_record_type(),
1502            id: "tix-1".into(),
1503            title: "One".into(),
1504            description: Some("body".into()),
1505            status: Some("in_progress".into()),
1506            priority: Some(1),
1507            issue_type: Some("task".into()),
1508            assignee: Some("alice".into()),
1509            owner: None,
1510            labels: vec!["beads".into()],
1511            dependencies: vec![BeadsDependency {
1512                issue_id: "tix-1".into(),
1513                depends_on_id: "tix-2".into(),
1514                kind: "blocks".into(),
1515            }],
1516            close_reason: None,
1517            created_at: None,
1518            updated_at: None,
1519        };
1520        let jsonl = to_jsonl(std::slice::from_ref(&row)).unwrap();
1521        assert_eq!(jsonl.lines().count(), 1);
1522        assert_eq!(parse_rows(&jsonl).unwrap(), vec![row.clone()]);
1523        // An array and an envelope are accepted too.
1524        let array = serde_json::to_string(&vec![row.clone()]).unwrap();
1525        assert_eq!(parse_rows(&array).unwrap(), vec![row.clone()]);
1526        let envelope = format!("{{\"schema_version\":1,\"data\":{array}}}");
1527        assert_eq!(parse_rows(&envelope).unwrap(), vec![row.clone()]);
1528        // A usage banner on stdout must not fail the read.
1529        let noisy = format!("Thanks for using bd!\n{jsonl}");
1530        assert_eq!(parse_rows(&noisy).unwrap(), vec![row]);
1531    }
1532
1533    #[test]
1534    fn exported_rows_omit_unset_optional_fields() {
1535        let config = Config::default();
1536        let resolution = resolution_for(&config);
1537        let plan = plan("tix", &resolution, &[issue(1, "One")], &[]);
1538        let json = serde_json::to_string(&plan.rows[0]).unwrap();
1539        assert!(!json.contains("close_reason"), "{json}");
1540        assert!(!json.contains("dependencies"), "{json}");
1541        assert!(!json.contains("assignee"), "{json}");
1542        assert!(json.contains("\"_type\":\"issue\""), "{json}");
1543    }
1544
1545    fn beads_row(id: &str, status: &str) -> BeadsRow {
1546        BeadsRow {
1547            record_type: issue_record_type(),
1548            id: id.to_string(),
1549            title: format!("issue {id}"),
1550            description: None,
1551            status: Some(status.to_string()),
1552            priority: Some(2),
1553            issue_type: Some("task".to_string()),
1554            assignee: None,
1555            owner: None,
1556            labels: Vec::new(),
1557            dependencies: Vec::new(),
1558            close_reason: None,
1559            created_at: None,
1560            updated_at: None,
1561        }
1562    }
1563
1564    #[test]
1565    fn a_fingerprint_is_stable_and_moves_with_the_row() {
1566        let row = beads_row("tix-1", "open");
1567        assert_eq!(fingerprint(&row).unwrap(), fingerprint(&row).unwrap());
1568
1569        let mut changed = row.clone();
1570        changed.status = Some("closed".to_string());
1571        assert_ne!(fingerprint(&row).unwrap(), fingerprint(&changed).unwrap());
1572
1573        // A dependency change must move the fingerprint of the row that carries it:
1574        // the edge is part of what beads is told about that issue.
1575        let mut edged = row.clone();
1576        edged.dependencies.push(BeadsDependency {
1577            issue_id: "tix-1".to_string(),
1578            depends_on_id: "tix-2".to_string(),
1579            kind: "blocks".to_string(),
1580        });
1581        assert_ne!(fingerprint(&row).unwrap(), fingerprint(&edged).unwrap());
1582    }
1583
1584    #[test]
1585    fn only_changed_rows_are_selected_and_all_forces_every_row() {
1586        let first = beads_row("tix-1", "open");
1587        let second = beads_row("tix-2", "open");
1588        let mut exported = BTreeMap::new();
1589        exported.insert(first.id.clone(), fingerprint(&first).unwrap());
1590
1591        let payload =
1592            select_payload(vec![first.clone(), second.clone()], &exported, false).unwrap();
1593        assert_eq!(payload.rows.len(), 1);
1594        assert_eq!(payload.rows[0].id, "tix-2");
1595        assert_eq!(payload.unchanged, 1);
1596        assert_eq!(payload.fingerprints.len(), 1);
1597
1598        // Nothing changed at all: the empty payload is what skips `bd import`.
1599        let payload = select_payload(vec![first.clone()], &exported, false).unwrap();
1600        assert_eq!(payload.rows, Vec::<BeadsRow>::new());
1601        assert_eq!(payload.unchanged, 1);
1602
1603        // `--all` ignores the watermark and sends everything.
1604        let payload = select_payload(vec![first, second], &exported, true).unwrap();
1605        assert_eq!(payload.rows.len(), 2);
1606        assert_eq!(payload.unchanged, 0);
1607        assert_eq!(payload.fingerprints.len(), 2);
1608    }
1609
1610    #[test]
1611    fn the_workspace_identity_follows_bd_precedence() {
1612        let tmp = tempfile::TempDir::new().unwrap();
1613        let root = tmp.path().join("project");
1614        std::fs::create_dir_all(root.join(".beads")).unwrap();
1615        let nested = root.join("a").join("b");
1616        std::fs::create_dir_all(&nested).unwrap();
1617        let canonical =
1618            |path: &std::path::Path| std::fs::canonicalize(path).unwrap().display().to_string();
1619        let beads = root.join(".beads");
1620        let found = Some(canonical(&beads));
1621
1622        // `BEADS_DIR` wins over everything, exactly as it does for `bd`.
1623        assert_eq!(
1624            workspace_identity_from(Some("/elsewhere/.beads"), Some(&beads), &nested),
1625            Some("/elsewhere/.beads".to_string())
1626        );
1627        // A blank `BEADS_DIR` is not a choice, so it falls through.
1628        assert_eq!(
1629            workspace_identity_from(Some("   "), Some(&beads), &nested),
1630            found.clone()
1631        );
1632        // The configured workspace beats discovery.
1633        assert_eq!(
1634            workspace_identity_from(None, Some(&beads), &nested),
1635            found.clone()
1636        );
1637        // Otherwise it is the nearest `.beads` walking up from the working
1638        // directory — here the project's own, found from two levels down.
1639        assert_eq!(workspace_identity_from(None, None, &nested), found);
1640
1641        // And nothing at all when no workspace is above the directory.
1642        let bare = tmp.path().join("bare");
1643        std::fs::create_dir_all(&bare).unwrap();
1644        assert_eq!(workspace_identity_from(None, None, &bare), None);
1645    }
1646
1647    struct FakeRunner {
1648        calls: RefCell<Vec<(String, Vec<String>)>>,
1649        ok: bool,
1650    }
1651
1652    impl CommandRunner for FakeRunner {
1653        fn run(
1654            &self,
1655            program: &str,
1656            args: &[String],
1657            _env: &[(String, String)],
1658        ) -> Result<CommandOutcome> {
1659            self.calls
1660                .borrow_mut()
1661                .push((program.to_string(), args.to_vec()));
1662            Ok(CommandOutcome {
1663                success: self.ok,
1664                status: Some(if self.ok { 0 } else { 1 }),
1665                stdout: "Imported 1 issues from tix-beads.jsonl".to_string(),
1666                stderr: if self.ok {
1667                    String::new()
1668                } else {
1669                    "boom".to_string()
1670                },
1671            })
1672        }
1673
1674        fn as_any(&self) -> &dyn std::any::Any {
1675            self
1676        }
1677    }
1678
1679    #[test]
1680    fn push_shells_out_to_bd_import_with_a_staged_file() {
1681        let config = Config::default();
1682        let resolution = resolution_for(&config);
1683        let plan = plan("tix", &resolution, &[issue(1, "One")], &[]);
1684        let runner = FakeRunner {
1685            calls: RefCell::new(Vec::new()),
1686            ok: true,
1687        };
1688        let stdout = push(&runner, "bd", &plan.rows).unwrap();
1689        assert!(stdout.contains("Imported 1 issues"));
1690
1691        let calls = runner.calls.borrow();
1692        assert_eq!(calls.len(), 1);
1693        let (program, args) = &calls[0];
1694        assert_eq!(program, "bd");
1695        assert_eq!(args[0], "import");
1696        assert!(args[1].ends_with(".jsonl"), "{args:?}");
1697    }
1698
1699    #[test]
1700    fn a_failed_push_is_an_external_error_naming_the_tool() {
1701        let runner = FakeRunner {
1702            calls: RefCell::new(Vec::new()),
1703            ok: false,
1704        };
1705        let err = push(&runner, "bd", &[]).unwrap_err();
1706        assert!(matches!(err, TixError::External(_)), "{err:?}");
1707        assert!(err.to_string().contains("bd import"));
1708    }
1709}