1use 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
70pub const TOOL: &str = "beads";
74
75pub const DEFAULT_BINARY: &str = "bd";
78
79pub const DEFAULT_ID_PREFIX: &str = "tix";
82
83const BEADS_TYPES: [&str; 6] = ["bug", "feature", "task", "epic", "chore", "decision"];
86
87#[must_use]
89pub fn beads_id(prefix: &str, number: u32) -> String {
90 format!("{prefix}-{number}")
91}
92
93fn issue_record_type() -> String {
98 "issue".to_string()
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct BeadsDependency {
104 pub issue_id: String,
106 pub depends_on_id: String,
108 #[serde(rename = "type")]
110 pub kind: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct BeadsRow {
118 #[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 #[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 #[must_use]
152 pub fn effective_assignee(&self) -> Option<&str> {
153 self.assignee.as_deref().or(self.owner.as_deref())
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct BeadsDiagnostic {
166 pub number: Option<u32>,
174 pub subject: String,
176 pub field: String,
178 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#[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#[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#[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#[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
264const 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
288fn 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
310fn 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
327fn 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
341fn relation_name(kind: RelationKind) -> &'static str {
343 match kind {
344 RelationKind::Blocks => "blocks",
345 RelationKind::Duplicates => "duplicates",
346 _ => "relates-to",
347 }
348}
349
350fn 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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
410pub struct BeadsPlan {
411 pub rows: Vec<BeadsRow>,
412 pub diagnostics: Vec<BeadsDiagnostic>,
413}
414
415#[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
506pub 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
523pub 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
558pub 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#[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
612pub 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#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct Detection {
635 pub binary: String,
637 pub available: bool,
639 pub workspace: bool,
641 pub version: Option<String>,
643 pub path: Option<String>,
645 pub journal: Option<bool>,
648 pub detail: String,
650}
651
652#[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#[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#[must_use]
745pub fn number_from_external_ref(reference: &str) -> Option<u32> {
746 reference.strip_prefix("tix#")?.parse().ok()
747}
748
749#[must_use]
752pub fn external_ref_for(number: u32) -> String {
753 format!("tix#{number}")
754}
755
756#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct MappedRow {
801 pub number: Option<u32>,
804 pub title: String,
805 pub body: String,
806 pub status: Option<String>,
808 pub priority: Priority,
809 pub type_: Option<String>,
810 pub labels: Vec<String>,
811 pub assignee: Option<String>,
812 pub beads_id: String,
814 pub updated_at: Option<String>,
816}
817
818#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
873pub struct State {
874 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
876 pub numbers: BTreeMap<String, u32>,
877 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
880 pub synced_at: BTreeMap<String, String>,
881 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
891 pub exported: BTreeMap<String, u64>,
892 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub workspace: Option<String>,
900}
901
902#[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
923fn 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
958pub 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
975fn 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
990pub struct BeadsPayload {
991 pub rows: Vec<BeadsRow>,
993 pub fingerprints: Vec<(String, u64)>,
996 pub unchanged: usize,
998}
999
1000pub 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
1023pub const STATE_FILE: &str = ".tix-beads-state.json";
1025
1026#[must_use]
1028pub fn state_path(root: &Path) -> PathBuf {
1029 root.join(STATE_FILE)
1030}
1031
1032#[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
1043pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
1057pub struct ImportReport {
1058 pub created: usize,
1059 pub updated: usize,
1060 pub skipped: usize,
1061 pub foreign: usize,
1066 pub diagnostics: Vec<BeadsDiagnostic>,
1067}
1068
1069fn 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
1113pub 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 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
1200pub 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 Ok(())
1251}
1252
1253#[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 #[serde(default)]
1271 pub issue: Option<BeadsRow>,
1272}
1273
1274pub 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 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
1323pub 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#[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#[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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
1624 workspace_identity_from(Some("/elsewhere/.beads"), Some(&beads), &nested),
1625 Some("/elsewhere/.beads".to_string())
1626 );
1627 assert_eq!(
1629 workspace_identity_from(Some(" "), Some(&beads), &nested),
1630 found.clone()
1631 );
1632 assert_eq!(
1634 workspace_identity_from(None, Some(&beads), &nested),
1635 found.clone()
1636 );
1637 assert_eq!(workspace_identity_from(None, None, &nested), found);
1640
1641 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}