tix/commands/export.rs
1//! `tix export --target <gitlab|linear|ics|views|health|roadmap|capacity|time|chart>
2//! [--dry-run] [--project <p>]`: mirror local issues to an external tracker, recording
3//! the remote id back into `gitlab_id`/`linear_id` so a re-run updates instead of
4//! duplicating (`contracts/external-tools.md`).
5//!
6//! Seven of the nine targets are **local** and never reach an external tool:
7//! `ics` writes a calendar for a cycle, `views` (WP07, mission
8//! `saved-issue-views-01M3C926`, T037) writes the portable saved-view document,
9//! `health` (WP05, mission `project-issue-health-status-01M3C8HX`) writes the
10//! portable health document, `roadmap` (WP07, mission
11//! `roadmap-initiatives-01M3C8SX`) writes the portable roadmap document,
12//! `capacity` (WP08, mission `cycle-capacity-planning-01M3C85C`, T043) writes the
13//! portable local capacity document, `time` (WP07, mission
14//! `per-issue-time-tracking-01M3C9A4`, T036/T037) writes the portable local time
15//! document, and `chart` (WP04, mission `burndown-scope-analytics-01M3C9J1`,
16//! T019–T023) writes the versioned local chart document.
17//! They are dispatched by name in [`run`] *before* `Tool::from_target` is
18//! consulted, which is what keeps the remote `gitlab`/`linear` namespaces
19//! separate from local view/health/initiative/capacity/time/chart data.
20
21use std::collections::BTreeSet;
22
23use crate::analytics::chart::ChartExport;
24use crate::analytics::events::{AnalyticsScope, Period};
25use crate::analytics::series::{Mode, ScopeSnapshot};
26use crate::analytics::units::estimate_series;
27use crate::capacity::portable::PortableCapacityDocument;
28use crate::cli::ExitCode;
29use crate::config::Config;
30use crate::cycle_schedule::identity::CycleReference;
31use crate::error::{Result, TixError};
32use crate::health::diagnostics::{HealthDiagnostic, HealthDiagnosticKind, RemoteTarget};
33use crate::health::events::{HealthScope, project};
34use crate::health::policy::HealthPolicy;
35use crate::health::portable::{PortableHealth, PortableHealthDiagnostic};
36use crate::ics::{self, Event};
37use crate::planning::Planner;
38use crate::roadmap::RoadmapFilter;
39use crate::roadmap::portable::PortableRoadmap;
40use crate::storage::Store;
41use crate::sync::{self, MappedHealth, Tool, beads};
42use crate::time_tracking::aggregate::TimeSnapshot;
43use crate::time_tracking::filters::TimeFilter;
44use crate::time_tracking::portable::PortableTimeDocument;
45use crate::views::portable::{PortableDocument, PortableRemoteTargets};
46
47/// Run the export orchestration, printing the report to stdout and every
48/// unsupported-field or token warning to stderr. A remote export with any
49/// unsupported local data returns `UsageConfig` after printing both streams, so
50/// exit `0` means the complete model travelled.
51// The signature mirrors the CLI's `Export` flags one for one — that is a
52// dispatcher's whole job — and bundling them into a struct would only move the
53// same destructuring to the caller. `commands::list::run` carries this allow for
54// the same reason.
55#[allow(clippy::too_many_arguments)]
56pub fn run(
57 config: &Config,
58 store: &Store,
59 target: &str,
60 dry_run: bool,
61 project_override: Option<&str>,
62 cycle: Option<&str>,
63 output: Option<&str>,
64 verbose: bool,
65) -> Result<ExitCode> {
66 if target == "ics" {
67 run_ics(config, store, cycle, output)?;
68 return Ok(ExitCode::Success);
69 }
70 if target == "views" {
71 run_views(config, output)?;
72 return Ok(ExitCode::Success);
73 }
74 if target == "health" {
75 run_health(config, output)?;
76 return Ok(ExitCode::Success);
77 }
78 if target == "roadmap" {
79 run_roadmap(config, output)?;
80 return Ok(ExitCode::Success);
81 }
82 if target == "capacity" {
83 run_capacity(config, store, cycle, output)?;
84 return Ok(ExitCode::Success);
85 }
86 if target == "time" {
87 run_time(config, store, output)?;
88 return Ok(ExitCode::Success);
89 }
90 if target == "chart" {
91 run_chart(config, store, cycle, output)?;
92 return Ok(ExitCode::Success);
93 }
94 if target == "beads" {
95 run_beads(config, store, dry_run, output, verbose)?;
96 return Ok(ExitCode::Success);
97 }
98 let outcome = run_remote_core(config, store, target, dry_run, project_override)?;
99 print!("{}", outcome.report);
100 for diagnostic in &outcome.diagnostics {
101 eprintln!("{diagnostic}");
102 }
103 Ok(if outcome.diagnostics.is_empty() {
104 ExitCode::Success
105 } else {
106 ExitCode::UsageConfig
107 })
108}
109
110#[derive(Debug)]
111struct RemoteExportOutcome {
112 report: String,
113 diagnostics: Vec<String>,
114}
115
116fn run_ics(
117 config: &Config,
118 store: &Store,
119 cycle: Option<&str>,
120 output: Option<&str>,
121) -> Result<()> {
122 let cycle_name = cycle.ok_or_else(|| TixError::InvalidInput("missing --cycle".into()))?;
123 let root =
124 Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
125 let planner = Planner::from_config(config.clone(), root);
126 let cycle = planner
127 .cycle(cycle_name)
128 .ok_or_else(|| TixError::NotFound(format!("cycle `{cycle_name}`")))?;
129
130 let cycle_start = cycle.start_date.clone().unwrap_or_else(crate::model::today);
131 if cycle.start_date.is_none() || cycle.due_date.is_none() {
132 eprintln!(
133 "{}",
134 crate::diagnostics::Diagnostic::note(
135 "N-tix-export",
136 format!("cycle `{cycle_name}` has incomplete dates; DTSTART fallback was applied")
137 )
138 .render_auto()
139 );
140 }
141 let cycle_end = cycle
142 .due_date
143 .as_deref()
144 .and_then(ics::plus_one_day)
145 .and_then(|s| ics::ymd_to_ics(&s));
146
147 let mut matching_issues: Vec<_> = store
148 .list()?
149 .into_iter()
150 .filter(|issue| {
151 issue
152 .cycles
153 .as_ref()
154 .is_some_and(|cycles| cycles.iter().any(|c| c == cycle_name))
155 })
156 .collect();
157 matching_issues.sort_by_key(|i| i.number);
158
159 let cycle_start_ics = ics::ymd_to_ics(&cycle_start).ok_or_else(|| {
160 TixError::InvalidInput(format!("invalid cycle start date `{cycle_start}`"))
161 })?;
162
163 let mut events = vec![Event {
164 uid: format!(
165 "cycle-{}-{}-{}@tix",
166 crate::slug::slugify(cycle_name),
167 cycle.start_date.as_deref().unwrap_or("none"),
168 cycle.due_date.as_deref().unwrap_or("none")
169 ),
170 summary: cycle_name.to_string(),
171 description: Some(format!(
172 "{} / {} issue(s)",
173 cycle.description.clone().unwrap_or_default(),
174 matching_issues.len()
175 )),
176 dtstart: Some(cycle_start_ics),
177 dtend: cycle_end,
178 }];
179 for issue in matching_issues {
180 let start = issue
181 .start_date
182 .clone()
183 .or_else(|| cycle.start_date.clone())
184 .unwrap_or_else(crate::model::today);
185 let end = issue
186 .due_date
187 .as_deref()
188 .or(cycle.due_date.as_deref())
189 .and_then(ics::plus_one_day)
190 .and_then(|s| ics::ymd_to_ics(&s));
191 events.push(Event {
192 uid: format!("issue-{}-{}@tix", issue.number, cycle_name),
193 summary: format!("[{}] {}", issue.status.display(), issue.title),
194 description: (!issue.body.trim().is_empty()).then_some(issue.body.clone()),
195 dtstart: ics::ymd_to_ics(&start),
196 dtend: end,
197 });
198 }
199 let calendar = ics::write_vcalendar(&events)?;
200 if let Some(path) = output {
201 ics::write_to_path(std::path::Path::new(path), &calendar)?;
202 println!("{path}");
203 } else {
204 print!("{calendar}");
205 }
206 Ok(())
207}
208
209/// `tix export --target views [--output <path>]` — the local portable saved-view
210/// document (WP07; T037).
211///
212/// A **local** target, like `ics`: it shells out to nothing, touches no issue
213/// file, and is dispatched by name before `Tool::from_target`, so the remote
214/// `gitlab`/`linear` namespaces are never consulted, never written, and cannot
215/// collide with a view. The document is the shared portable DTO
216/// ([`crate::views::portable`]) built by the one mapping every surface uses.
217///
218/// Side-effect policy: the document goes to stdout, or to `--output` through the
219/// existing atomic export writer (temp file + rename), which is the same helper
220/// `ics` uses. Diagnostics — an unknown field, a remote-target-shaped key, an
221/// executable-shaped key, a foreign format/version marker — go to **stderr**, so
222/// they are reported without corrupting the machine-readable stdout. They are
223/// informational, never a refusal and never a drop: the value they are about is
224/// retained in the document (NFR-002).
225/// `tix export --target health`: the portable health document, as the machine-readable
226/// form of everything `tix health` prints (WP05 T025's "parseable output", C-007).
227///
228/// Pretty JSON to a file or to stdout, exactly as `--target views` does; diagnostics
229/// go to stderr so they are reported without corrupting the document on stdout.
230pub fn run_health(config: &Config, output: Option<&str>) -> Result<()> {
231 let document = run_health_core(config)?;
232 for diagnostic in &document.diagnostics {
233 eprintln!("diagnostic: {}", diagnostic.to_diagnostic().render());
234 }
235 let json = document.to_json_pretty()?;
236 match output {
237 Some(path) => {
238 ics::write_to_path(std::path::Path::new(path), &json)?;
239 println!("{path}");
240 }
241 None => print!("{json}"),
242 }
243 Ok(())
244}
245
246/// `tix export --target roadmap [--output <path>]` — the local portable roadmap
247/// document (WP07; T032/T033).
248///
249/// A **local** target, like `ics`, `views` and `health`: it shells out to nothing,
250/// touches no initiative record and no issue file, and is dispatched by name before
251/// `Tool::from_target`, so the remote `gitlab`/`linear` namespaces are never
252/// consulted and cannot collide with an initiative. The document is the shared DTO
253/// ([`crate::roadmap::portable`]) built from the one projection every surface reads,
254/// so `tix roadmap list`, the TUI, MCP and this file cannot disagree.
255///
256/// Pretty JSON to a file or to stdout; diagnostics — a missing reference, an empty
257/// summary, an unsupported remote field — go to **stderr**, so they are reported
258/// without corrupting the document on stdout. They are informational, never a
259/// refusal and never a drop: every value they are about is retained in the
260/// document (C-005/C-009).
261pub fn run_roadmap(config: &Config, output: Option<&str>) -> Result<()> {
262 let document = run_roadmap_core(config)?;
263 for diagnostic in &document.diagnostics {
264 eprintln!("diagnostic: {}", diagnostic.render());
265 }
266 let json = document.to_json_pretty()?;
267 match output {
268 Some(path) => {
269 ics::write_to_path(std::path::Path::new(path), &json)?;
270 println!("{path}");
271 }
272 None => print!("{json}"),
273 }
274 Ok(())
275}
276
277/// The roadmap export core: the versioned document, with no printing and no writing.
278///
279/// Split out of [`run_roadmap`] so an MCP/portable parity check or a test can read
280/// exactly the document the CLI emits without capturing stdout (the same split
281/// [`run_core`], [`run_health_core`] and [`run_views_core`] offer).
282///
283/// It reads the **same** source snapshot the CLI roadmap reads, through
284/// [`crate::commands::roadmap::build_sources`] — the one gathering of the consumed
285/// planning/relationship facts — so the document cannot become a second definition
286/// of what an initiative's summary is. Read-only: nothing is written, and the only
287/// side effects are the reads the projection already makes.
288pub fn run_roadmap_core(config: &Config) -> Result<PortableRoadmap> {
289 let store = Store::open(config)?;
290 let issues = store.list_all()?;
291 let sources = crate::commands::roadmap::build_sources(config, &store, &issues)?;
292 // The empty filter is the documented "no filter" default: `tix export` takes no
293 // roadmap filter flags, so the export is the whole ordered roadmap, and the
294 // document carries that normalized filter so a reader can see it was empty.
295 let document = PortableRoadmap::build(config, &sources, &RoadmapFilter::default())?
296 .with_remote_mappings(config);
297 Ok(document)
298}
299
300/// `tix export --target capacity [--cycle <REFERENCE>] [--output <path>]` — the local
301/// portable capacity document (WP08; T043).
302///
303/// A **local** target, like `ics`, `views`, `health` and `roadmap`: it shells out to
304/// nothing, touches no issue file and no remote tracker, and is dispatched by name before
305/// [`Tool::from_target`] is consulted, so the remote `gitlab`/`linear` namespaces are
306/// never consulted, never written, and cannot collide with a capacity value. The document
307/// is the shared DTO ([`PortableCapacityDocument`]) whose every number is the shared
308/// calculation's.
309///
310/// Side-effect policy: the document goes to stdout, or to `--output` through the existing
311/// atomic export writer (temp file + rename) the other local targets use. Diagnostics — an
312/// unknown preserved key, a version mismatch, and the [`UNSUPPORTED_REMOTE_FIELD`] finding
313/// that says a configured remote tracker cannot carry capacity — go to **stderr**, so they
314/// are reported without corrupting the machine-readable stdout. They are informational,
315/// never a refusal and never a drop: every value they are about stays in the document.
316///
317/// [`UNSUPPORTED_REMOTE_FIELD`]: crate::capacity::portable::UNSUPPORTED_REMOTE_FIELD
318pub fn run_capacity(
319 config: &Config,
320 store: &Store,
321 cycle: Option<&str>,
322 output: Option<&str>,
323) -> Result<()> {
324 let document = run_capacity_core(config, store, cycle)?;
325 for diagnostic in &document.diagnostics {
326 eprintln!("diagnostic: {}", diagnostic.render());
327 }
328 let json = document.to_json_pretty()?;
329 match output {
330 Some(path) => {
331 ics::write_to_path(std::path::Path::new(path), &json)?;
332 println!("{path}");
333 }
334 None => print!("{json}"),
335 }
336 Ok(())
337}
338
339/// The portable-capacity export core: the document, with no printing and no writing.
340///
341/// Split out of [`run_capacity`] so an MCP/portable parity check or a test can read exactly
342/// the document the CLI emits without capturing stdout (the same split [`run_core`],
343/// [`run_health_core`], [`run_views_core`] and [`run_roadmap_core`] offer).
344///
345/// It reads the **same** snapshot/index pair `tix capacity calculate` reads, through
346/// [`crate::commands::capacity::snapshot_for`] — the one builder of the pair — so a
347/// document's numbers cannot become a second reading of the same repository. `cycle` is
348/// the CLI's `--cycle`; when it names one, the document is narrowed to that cycle (the
349/// reference is folded by the library, so a generated cycle's name and its stable id
350/// select the same document). Read-only: nothing is written, and the only side effects are
351/// the reads the calculation and the snapshot store already make.
352pub fn run_capacity_core(
353 config: &Config,
354 store: &Store,
355 cycle: Option<&str>,
356) -> Result<PortableCapacityDocument> {
357 let root =
358 Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
359 let (index, snapshot) = crate::commands::capacity::snapshot_for(config, &root, store)?;
360 match cycle {
361 Some(reference) => {
362 PortableCapacityDocument::build_for_cycle(config, &root, &index, &snapshot, reference)
363 }
364 None => PortableCapacityDocument::build(config, &root, &index, &snapshot),
365 }
366}
367
368/// `tix export --target time [--output <path>]` — the local portable time document
369/// (WP07; T036/T037).
370///
371/// A **local** target, like `ics`, `views`, `health`, `roadmap` and `capacity`: it
372/// shells out to nothing, touches no issue file and no remote tracker, and is dispatched
373/// by name before [`Tool::from_target`] is consulted, so the remote `gitlab`/`linear`
374/// namespaces are never consulted, never written, and cannot collide with a time entry.
375/// The document is the shared DTO ([`PortableTimeDocument`]): every number is the shared
376/// calculation's and every entry is the issue's own current value, so `tix time total`
377/// and this file cannot disagree.
378///
379/// Side-effect policy: the document goes to stdout, or to `--output` through the existing
380/// atomic export writer (temp file + rename) the other local targets use. Diagnostics — an
381/// entry's field problem, a preserved metadata key, and the
382/// [`UNSUPPORTED_REMOTE_FIELD`] finding that says a configured remote tracker cannot
383/// carry time — go to **stderr**, so they are reported without corrupting the
384/// machine-readable stdout. They are informational, never a refusal and never a drop:
385/// every value they are about stays in the document. A refused scope is the exception and
386/// exits non-zero with the shared findings and their remedies, exactly as `tix time total`
387/// refuses it.
388///
389/// [`UNSUPPORTED_REMOTE_FIELD`]: crate::time_tracking::portable::UNSUPPORTED_REMOTE_FIELD
390pub fn run_time(config: &Config, store: &Store, output: Option<&str>) -> Result<()> {
391 let document = run_time_core(config, store)?;
392 for diagnostic in &document.diagnostics {
393 eprintln!("diagnostic: {}", diagnostic.render());
394 }
395 let json = document.to_json_pretty()?;
396 match output {
397 Some(path) => {
398 ics::write_to_path(std::path::Path::new(path), &json)?;
399 println!("{path}");
400 }
401 None => print!("{json}"),
402 }
403 Ok(())
404}
405
406/// The portable-time export core: the versioned document, with no printing and no
407/// writing.
408///
409/// Split out of [`run_time`] so an MCP/portable parity check or a test can read exactly
410/// the document the CLI emits without capturing stdout (the same split [`run_core`],
411/// [`run_health_core`], [`run_views_core`], [`run_roadmap_core`] and
412/// [`run_capacity_core`] offer).
413///
414/// It reads the **same** snapshot `tix time total` reads — every active issue, as
415/// `tix list` enumerates them, indexed once by [`TimeSnapshot::of`] — under the
416/// documented "no filter" default ([`TimeFilter::new`], which the export takes no flags
417/// to narrow), so a document's numbers cannot become a second reading of the repository.
418/// Read-only: nothing is written, and the only side effects are the reads the store
419/// already makes.
420pub fn run_time_core(config: &Config, store: &Store) -> Result<PortableTimeDocument> {
421 let snapshot = TimeSnapshot::of(&store.list()?);
422 PortableTimeDocument::build(config, &snapshot, &TimeFilter::new())
423}
424
425/// `tix export --target chart --cycle <REFERENCE> [--output <path>]` — the versioned
426/// local chart document (WP04, mission `burndown-scope-analytics-01M3C9J1`,
427/// T019–T023).
428///
429/// A **local** target, like `ics`, `views`, `health`, `roadmap`, `capacity` and `time`:
430/// it shells out to nothing, touches no issue file and no remote tracker, and is
431/// dispatched by name before [`Tool::from_target`] is consulted, so the remote
432/// `gitlab`/`linear` namespaces are never consulted, never written, and cannot collide
433/// with a chart document. The document is WP04's [`ChartExport`] — its scope, period,
434/// mode, unit, points, optional projection, coverage and diagnostics all come from the
435/// one shared fold, so this file cannot disagree with `tix analytics burndown`.
436///
437/// The export flags carry **one** scope reference (`--cycle`) and no mode/unit flag: the
438/// local target therefore documents the cycle's **count**-mode series. The reference is
439/// folded through [`cycle_scope_for`] — the crate's one cycle identity adapter — so a
440/// generated cycle's **name** and its stable `<schedule-id>:<sequence>` id select the
441/// same document. A caller selecting estimate mode, a milestone scope or an end-date
442/// forecast goes through WP05's `tix analytics export` verb, which builds the same
443/// document through [`chart_document`] — the target owns the document, the verb owns the
444/// flags (analysis-report H9). A missing `--cycle` is the same `InvalidInput` `ics`
445/// reports; a reference nothing resolves to is a `not_found`.
446///
447/// Side-effect policy: the document goes to stdout, or to `--output` through the existing
448/// atomic export writer (temp file + rename) the other local targets use. Diagnostics —
449/// the remote-tracker and version findings — go to **stderr**, so they are reported
450/// without corrupting the machine-readable stdout. They are informational, never a
451/// refusal and never a drop: every value they are about stays in the document (NFR-002).
452pub fn run_chart(
453 config: &Config,
454 store: &Store,
455 cycle: Option<&str>,
456 output: Option<&str>,
457) -> Result<()> {
458 let document = run_chart_core(config, store, cycle)?;
459 for diagnostic in &document.diagnostics {
460 eprintln!("diagnostic: {}", diagnostic.render());
461 }
462 let json = document.to_json_pretty()?;
463 match output {
464 Some(path) => {
465 ics::write_to_path(std::path::Path::new(path), &json)?;
466 println!("{path}");
467 }
468 None => print!("{json}"),
469 }
470 Ok(())
471}
472
473/// The local chart export core: the referenced cycle's count-mode document, with no
474/// printing and no writing.
475///
476/// Split out of [`run_chart`] so a test or an MCP/portable parity check can read exactly
477/// the document the CLI emits without capturing stdout (the same split [`run_core`],
478/// [`run_views_core`], [`run_health_core`], [`run_roadmap_core`], [`run_capacity_core`]
479/// and [`run_time_core`] offer). It resolves the reference through [`cycle_scope_for`] and
480/// delegates to [`chart_document`].
481pub fn run_chart_core(config: &Config, store: &Store, cycle: Option<&str>) -> Result<ChartExport> {
482 let reference = cycle.ok_or_else(|| TixError::InvalidInput("missing --cycle".into()))?;
483 let scope = cycle_scope_for(config, store, reference)?;
484 chart_document(config, store, &scope, Mode::Count, None, None)
485}
486
487/// The cycle scope a chart export names, from the caller's reference (T019/T022).
488///
489/// The reference is folded through the crate's one cycle identity adapter
490/// ([`crate::capacity::resolve_cycle`]) over the project's own schedule index, so a
491/// generated cycle's **name** and its stable `<schedule-id>:<sequence>` id select the
492/// same scope — and therefore the same document — exactly as `tix capacity` resolves a
493/// scope. The scope's id is the resolved cycle's canonical **name** rather than the
494/// spelling the caller wrote, which is what makes the two spellings one document rather
495/// than two that merely agree on their members.
496///
497/// The period is the resolved cycle's own declared `start_date`/`due_date`: the store
498/// record's when the project has one (a generated cycle's dates live there and nowhere in
499/// `tix.toml`), the legacy `[cycles.<name>]` table's otherwise — the two provenances
500/// [`crate::capacity::schedule_index`] builds its index from. A cycle that declares
501/// neither bound stays unbounded on that side, which the series reports as coverage
502/// rather than guessing.
503///
504/// A reference nothing resolves to is a `not_found`, not an empty document — the same
505/// answer `tix export --target ics` gives, with the stable-id spelling added.
506pub fn cycle_scope_for(config: &Config, store: &Store, reference: &str) -> Result<AnalyticsScope> {
507 let root =
508 Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
509 let index = crate::capacity::schedule_index(config, &root)?;
510 let name = match crate::capacity::resolve_cycle(&index, reference) {
511 CycleReference::Generated { name, .. } | CycleReference::Legacy { name } => name,
512 CycleReference::Unknown { name } => {
513 return Err(TixError::NotFound(format!("cycle `{name}`")));
514 }
515 };
516 let period = cycle_period(config, &root, &name)?;
517 AnalyticsScope::cycle(name, period)
518}
519
520/// The declared period of the cycle `name` resolves to.
521///
522/// The store's record first — a generated cycle's dates are the record's, and a
523/// store-less project's store list is the implicit `Default` cycle — then the legacy
524/// `[cycles.<name>]` table, which is the whole registry of a project with no
525/// `docs/cycles/`. Neither is a guess: a bound the cycle does not declare stays absent,
526/// and the series reports that as coverage.
527///
528/// A name the index answered for but that neither registry declares is left unbounded
529/// rather than invented; the series then refuses to enumerate the period, which is the
530/// visible answer this module gives when a reference resolves to nothing it can date.
531fn cycle_period(config: &Config, root: &std::path::Path, name: &str) -> Result<Period> {
532 if let Some(record) = crate::cycle_store::CycleStore::new(root)
533 .list()?
534 .into_iter()
535 .find(|record| record.name == name)
536 {
537 return Period::new(record.start_date, record.due_date);
538 }
539 match config.cycles.get(name) {
540 Some(cycle) => Period::new(cycle.start_date.clone(), cycle.due_date.clone()),
541 None => Ok(Period::default()),
542 }
543}
544
545/// The one chart-document core every surface reads (T019–T023).
546///
547/// `scope` is the **already-resolved** scope: the caller folds its `--cycle` reference
548/// through [`cycle_scope_for`] and its `--milestone` reference through
549/// [`milestone_scope`](crate::analytics::events::milestone_scope). Either way the fold
550/// keys membership on the identity the scope's `id` resolves to rather than on the
551/// spelling, so a scope built from a cycle's name and one built from its stable id select
552/// the same members; [`cycle_scope_for`] canonicalizes to the name, so they are also the
553/// same document. `mode`/`unit` are the caller's choice; `unit` is required in estimate
554/// mode (an issue count is unitless, so count mode ignores it) and is resolved by WP03's
555/// [`estimate_series`], which is the crate's one estimate entry point. `projection` is
556/// the caller's `(as_of, end_date)` pair, both dates required together and both the
557/// caller's own, never a clock: when it is absent the document carries no forecast.
558///
559/// It reads the store's issues **once** and folds them through WP02's [`ScopeSnapshot`],
560/// so a document's numbers are the shared fold's and this file recomputes nothing; the
561/// configured remote targets add their own findings. Read-only: nothing is written, and
562/// the only side effects are the reads the fold already makes.
563pub fn chart_document(
564 config: &Config,
565 store: &Store,
566 scope: &AnalyticsScope,
567 mode: Mode,
568 unit: Option<&str>,
569 projection: Option<(&str, &str)>,
570) -> Result<ChartExport> {
571 let root =
572 Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
573 let index = crate::capacity::schedule_index(config, &root)?;
574 let issues = store.list_all()?;
575 let snapshot = ScopeSnapshot::build(config, &index, &issues, scope);
576 let series = match mode {
577 Mode::Count => snapshot.series(Mode::Count),
578 Mode::Estimate => {
579 let unit = unit.ok_or_else(|| {
580 TixError::InvalidInput("estimate mode needs a unit (--unit)".into())
581 })?;
582 estimate_series(&snapshot, &issues, config, unit)
583 }
584 };
585 let projection = match projection {
586 Some((as_of, end_date)) => Some(crate::analytics::projection::project(
587 &series, as_of, end_date,
588 )?),
589 None => None,
590 };
591 let targets = configured_remote_targets(config);
592 Ok(ChartExport::build(&series, projection.as_ref(), &targets))
593}
594
595/// The configured remote trackers that cannot carry a chart document, in a stable order.
596///
597/// The names are the `[export.*]` sections' own (`gitlab`, then `linear`), so the
598/// finding names the target the same way `tix export` does. Nothing is resolved beyond
599/// the presence of the section: this is the export's *diagnostic* half, not a mapping.
600fn configured_remote_targets(config: &Config) -> Vec<&'static str> {
601 let mut targets = Vec::new();
602 if config.export.gitlab.is_some() {
603 targets.push("gitlab");
604 }
605 if config.export.linear.is_some() {
606 targets.push("linear");
607 }
608 targets
609}
610
611/// `tix export --target beads` — the CLI face of [`run_beads_core`].
612///
613/// **Writes through `bd`**, never the Dolt database: the rows are staged as JSONL
614/// and handed to `bd import`, which upserts by their deterministic id
615/// (`tix-<number>`), so a re-run updates instead of duplicating.
616///
617/// The one thing the CLI adds over the core is `--output <path>`, which writes the
618/// JSONL document instead of pushing it — the same rows `bd import` would receive,
619/// so a caller can inspect or pipe them.
620fn run_beads(
621 config: &Config,
622 store: &Store,
623 dry_run: bool,
624 output: Option<&str>,
625 verbose: bool,
626) -> Result<()> {
627 if let Some(path) = output {
628 // `--output` writes the **whole** document, never the incremental diff: the
629 // reason to name a file is to inspect or pipe a complete interchange, and a
630 // caller piping it into another workspace has no state file to be
631 // incremental against. `tix beads push` is the incremental face.
632 let plan = beads_plan(config, store)?;
633 if verbose {
634 for diagnostic in &plan.diagnostics {
635 eprintln!("{diagnostic}");
636 }
637 }
638 ics::write_to_path(std::path::Path::new(path), &beads::to_jsonl(&plan.rows)?)?;
639 println!("{path}");
640 return Ok(());
641 }
642 let report = run_beads_core(
643 config,
644 store,
645 ExportOptions {
646 dry_run,
647 all: false,
648 verbose,
649 },
650 )?;
651 if dry_run && report.trim().is_empty() {
652 // An empty dry run is the *good* case, and silence would read as a failure.
653 eprintln!("nothing changed since the last export (pass --all to send everything)");
654 }
655 print!("{report}");
656 Ok(())
657}
658
659/// The beads rows for this store, built in **one** place.
660///
661/// The export target, the `--output` document, and the `beads` command group's
662/// `push` all come through here, so they cannot disagree about the mapping or the
663/// id prefix.
664fn beads_plan(config: &Config, store: &Store) -> Result<beads::BeadsPlan> {
665 let resolution = crate::workflow::resolution(config, config.default_config(), "default");
666 let issues = store.list()?;
667 // **One** pass over the whole graph. A `relationships_for(issue.number)` loop
668 // would read every issue file and rebuild the graph once per issue — millions
669 // of file reads at this repository's size — where this reads once.
670 let relationships = store.all_relationships()?;
671 Ok(beads::plan(
672 &config.beads.id_prefix,
673 &resolution,
674 &issues,
675 &relationships,
676 ))
677}
678
679/// How one beads export should behave.
680///
681/// A struct rather than four positional booleans: `run_beads_core` is reached from
682/// three surfaces (the `export` target, `tix beads push`, and the MCP tool), and
683/// a call site that says `ExportOptions { all: true, .. }` cannot silently pass
684/// `all` where `verbose` was meant.
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub struct ExportOptions {
687 /// Print the JSONL a real run would send, and write nothing at all.
688 pub dry_run: bool,
689 /// Ignore the export watermark and send every issue.
690 pub all: bool,
691 /// List the per-issue diagnostics. Off by default: a board of this size
692 /// produces hundreds, and a run that sends three issues should not print the
693 /// local-field notes for the other 1700.
694 pub verbose: bool,
695}
696
697/// Above this many rows an import is long enough that a reader starts to wonder
698/// whether it is stuck.
699const SLOW_EXPORT_THRESHOLD: usize = 100;
700
701/// The pre-flight line: what is going, and a word when that is a lot.
702///
703/// A function rather than an `eprintln!` written inline so the threshold is
704/// testable at its own boundary — a hundred rows say nothing, a hundred and one
705/// say so.
706fn preflight_line(sending: usize, total: usize, unchanged: usize) -> String {
707 let caveat = if sending > SLOW_EXPORT_THRESHOLD {
708 " — this may take a while..."
709 } else {
710 ""
711 };
712 format!("exporting {sending} of {total} issue(s) to beads ({unchanged} unchanged){caveat}")
713}
714
715/// The shared beads export.
716///
717/// **Incremental by default.** Each row is fingerprinted ([`beads::fingerprint`])
718/// and compared with the fingerprint recorded for its beads id in the state file;
719/// a row that matches has not changed since it was last sent, so it is not sent
720/// again. A second run with nothing changed therefore does **no** `bd import` at
721/// all — which is the difference between one pass over the tree and a full
722/// 1700-row upsert every time.
723///
724/// `options.all` forces every row through, which is what a lost state file (or a
725/// workspace rebuilt from scratch) needs; `tix beads push --all`.
726///
727/// `options.dry_run` returns the JSONL of the rows a real run would send — the
728/// changed ones — and, like every dry run, writes nothing: not to beads, and not
729/// to the state file either, so previewing does not advance the watermark.
730///
731/// ## Which stream says what
732///
733/// **stdout carries the report** (and, on a dry run, the JSONL itself, so it can
734/// be piped into `bd import`), and **stderr carries the progress and the
735/// per-issue diagnostics**. The pre-flight count goes out *before* `bd import`
736/// starts, because a run over thousands of issues is otherwise silent for the
737/// part a reader is waiting on.
738pub fn run_beads_core(config: &Config, store: &Store, options: ExportOptions) -> Result<String> {
739 let ExportOptions {
740 dry_run,
741 all,
742 verbose,
743 } = options;
744 let root =
745 Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
746 let beads::BeadsPlan { rows, diagnostics } = beads_plan(config, store)?;
747
748 let workspace = beads::workspace_identity(config);
749 let mut state = beads::load_state(&root);
750 // A watermark is only meaningful for the workspace it was written against.
751 // Without this, pointing tix at a different workspace would look like "every
752 // issue was already exported" and send *nothing* — silently.
753 let workspace_changed = !state.exported.is_empty() && state.workspace != workspace;
754 if workspace_changed {
755 state.exported.clear();
756 }
757 let beads::BeadsPayload {
758 rows: payload,
759 fingerprints,
760 unchanged,
761 } = beads::select_payload(rows, &state.exported, all)?;
762
763 // Only the diagnostics belonging to the rows being **sent** are worth a word: a
764 // repeat run that sends nothing must not replay three hundred "cannot
765 // represent" lines about issues it did not touch.
766 let sent: BTreeSet<u32> = payload
767 .iter()
768 .filter_map(|row| beads::number_from_id(&row.id, &config.beads.id_prefix))
769 .collect();
770 let reported: Vec<&beads::BeadsDiagnostic> = diagnostics
771 .iter()
772 .filter(|diagnostic| {
773 diagnostic
774 .number
775 .is_none_or(|number| sent.contains(&number))
776 })
777 .collect();
778
779 if workspace_changed {
780 // Before anything else, because it explains why everything is going again.
781 eprintln!(
782 "the beads workspace changed since the last export; the export watermark was reset"
783 );
784 }
785 if verbose {
786 for diagnostic in &reported {
787 eprintln!("{diagnostic}");
788 }
789 }
790
791 if dry_run {
792 return beads::to_jsonl(&payload);
793 }
794 let total = payload.len() + unchanged;
795 if payload.is_empty() {
796 return Ok(format!(
797 "nothing to export: all {unchanged} issue(s) are unchanged since the last export\n"
798 ));
799 }
800
801 // The one line worth printing *before* an import that can take a while.
802 eprintln!("{}", preflight_line(payload.len(), total, unchanged));
803 let mut report = beads::push(&crate::sync::ProcessRunner, &config.beads.binary, &payload)?;
804 // The watermark advances only after `bd import` succeeded, so a failed export
805 // is retried whole rather than being silently recorded as done.
806 for (id, fingerprint) in fingerprints {
807 state.exported.insert(id, fingerprint);
808 }
809 state.workspace = workspace;
810 beads::save_state(&root, &state)?;
811
812 let hidden = if verbose || reported.is_empty() {
813 String::new()
814 } else {
815 "; pass --verbose to list them".to_string()
816 };
817 report.push_str(&format!(
818 "exported {} of {} issue(s) to beads ({} unchanged, {} diagnostic(s) — local data beads \
819 cannot hold{hidden})\n",
820 payload.len(),
821 total,
822 unchanged,
823 reported.len()
824 ));
825 Ok(report)
826}
827
828pub fn run_views(config: &Config, output: Option<&str>) -> Result<()> {
829 let document = run_views_core(config)?;
830 for diagnostic in document.all_diagnostics() {
831 eprintln!("diagnostic: {}", diagnostic.render());
832 }
833 let json = document.to_json_pretty()?;
834 match output {
835 Some(path) => {
836 ics::write_to_path(std::path::Path::new(path), &json)?;
837 println!("{path}");
838 }
839 None => print!("{json}"),
840 }
841 Ok(())
842}
843
844/// The portable-view export core: the document, with no printing and no writing.
845///
846/// Split out of [`run_views`] so an MCP/portable parity check or a test can read
847/// exactly the document the CLI emits without capturing stdout (the same split
848/// [`run_core`] offers the remote targets).
849pub fn run_views_core(config: &Config) -> Result<PortableDocument> {
850 let mut document =
851 PortableDocument::from_registry(&config.views, &project_directory(), None, None);
852 document.remote = PortableRemoteTargets::from_config(config);
853 Ok(document)
854}
855
856/// The health export core (WP04): every health fact in the repository, as the one
857/// portable DTO the CLI, the TUI, and MCP all render (C-007).
858///
859/// It reads and derives — issues, the memberships the planning services declare, the
860/// index, the rollups — and never writes. Health is read-only on every surface
861/// (C-007), so there is no `--apply` for it to honour.
862///
863/// When a remote target is configured, the document also carries what that target
864/// cannot represent, as `unsupported_remote_field` diagnostics naming the field and
865/// the target (T023): the export proves the value stays local instead of asking a
866/// reader to trust that it did.
867pub fn run_health_core(config: &Config) -> Result<PortableHealth> {
868 let store = Store::open(config)?;
869 let issues = store.list_all()?;
870 // The planner reads the *project's* cycles and milestones, not the process's
871 // working directory's: a test fixture gets its own, and a command run from
872 // elsewhere does not pick up an unrelated repository's configuration.
873 let root =
874 Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
875 let planner = Planner::from_config(config.clone(), root);
876 let cycles = planner.cycle_names();
877 let milestones: Vec<String> = planner
878 .list_milestones()?
879 .into_iter()
880 .map(|milestone| milestone.name)
881 .collect();
882 let policy = HealthPolicy::v1();
883 let mut document = PortableHealth::build(&issues, &cycles, &milestones, &policy);
884
885 // The remote half: one diagnostic per configured target, per issue-scoped value.
886 let targets: Vec<(Tool, RemoteTarget)> = [
887 (
888 config.export.gitlab.is_some(),
889 Tool::GitLab,
890 RemoteTarget::GitLab,
891 ),
892 (
893 config.export.linear.is_some(),
894 Tool::Linear,
895 RemoteTarget::Linear,
896 ),
897 ]
898 .into_iter()
899 .filter_map(|(configured, tool, target)| configured.then_some((tool, target)))
900 .collect();
901 if targets.is_empty() {
902 return Ok(document);
903 }
904 for issue in &issues {
905 let view = project(issue, &policy);
906 let scope = HealthScope::of_issue(issue.number);
907 for (tool, target) in &targets {
908 let MappedHealth::Unsupported(diagnostic) =
909 sync::map_health_to_remote(*tool, &scope, view.state)
910 else {
911 continue;
912 };
913 let wire = HealthDiagnostic::new(
914 HealthDiagnosticKind::UnsupportedRemoteField,
915 scope.clone(),
916 diagnostic.message.clone(),
917 )
918 .referencing(diagnostic.relation_id.clone())
919 .at_field(diagnostic.field.clone())
920 .for_target(*target)
921 .carrying(view.state.as_str());
922 document
923 .diagnostics
924 .push(PortableHealthDiagnostic::from_diagnostic(&wire));
925 }
926 }
927 document.diagnostics.sort_by(|left, right| {
928 (&left.kind, &left.scope, &left.message).cmp(&(&right.kind, &right.scope, &right.message))
929 });
930 document
931 .diagnostics
932 .dedup_by(|left, right| left.same_as(right));
933 Ok(document)
934}
935
936/// The project ids a project-scoped view can resolve against.
937///
938/// Empty for the same reason `commands::views::project_directory` is: `tix` has no
939/// first-class project registry in this build. A project-scoped view therefore
940/// exports with `resolution = "missing"` and the registry's own diagnostic rather
941/// than being silently re-scoped to the repository.
942fn project_directory() -> BTreeSet<String> {
943 BTreeSet::new()
944}
945
946/// The export core (WP03): run the export orchestration and return the full
947/// stdout report as a `String` — including the `nothing to export` line on an
948/// empty store, so the MCP `export_issues` tool (WP04) can mirror CLI stdout
949/// byte-for-byte (NFR-005).
950///
951/// The BAIL-early config gates (`no [export.gitlab] section`, missing
952/// `project`) and their exact messages live in `sync`, and are surfaced here
953/// untouched (C-006). Side-effect policy (C-005): the report is captured into
954/// a buffer, never printed; the CLI renders it in [`run`] (which also keeps
955/// `token::resolve` warnings on stderr as today).
956///
957/// `target` is `None` for a missing required arg — caught by the tool layer
958/// before it reaches here per the MCP schema — and surfaces as the same
959/// `unknown --target`-style `InvalidInput` error class (C-006).
960pub fn run_core(
961 config: &Config,
962 store: &Store,
963 target: Option<&str>,
964 dry_run: bool,
965 project_override: Option<&str>,
966) -> Result<String> {
967 let target = target.ok_or_else(|| {
968 TixError::InvalidInput("missing --target — use gitlab or linear".to_string())
969 })?;
970 // `beads` is a local target reached through the `bd` CLI, not a tracker with a
971 // `Tool` adapter, so it branches **before** the tracker validation — the same
972 // reason the import path does. Both the CLI and the MCP `export_issues` tool
973 // arrive here, so both get it.
974 if target == "beads" {
975 return run_beads_core(
976 config,
977 store,
978 ExportOptions {
979 dry_run,
980 // The MCP tool takes neither `--all` nor `--verbose`: it is an
981 // incremental export that reports its summary, and a tool result is
982 // not a place for hundreds of per-issue lines.
983 all: false,
984 verbose: false,
985 },
986 );
987 }
988 let outcome = run_remote_core(config, store, target, dry_run, project_override)?;
989 let mut text = outcome.report;
990 for diagnostic in outcome.diagnostics {
991 text.push_str(&diagnostic);
992 text.push('\n');
993 }
994 Ok(text)
995}
996
997/// Run one remote export without choosing a presentation stream or exit status.
998fn run_remote_core(
999 config: &Config,
1000 store: &Store,
1001 target: &str,
1002 dry_run: bool,
1003 project_override: Option<&str>,
1004) -> Result<RemoteExportOutcome> {
1005 let tool = Tool::from_target(target)?;
1006 let config = apply_project_override(config, tool, project_override);
1007 let mut report: Vec<u8> = Vec::new();
1008 let outcome = sync::export(store, tool, &config, dry_run, &mut report)?;
1009 let handled = outcome.handled;
1010 if handled == 0 {
1011 report.extend_from_slice(b"nothing to export\n");
1012 }
1013 Ok(RemoteExportOutcome {
1014 report: String::from_utf8(report).expect("sync reports contain only UTF-8 text"),
1015 diagnostics: outcome
1016 .diagnostics
1017 .iter()
1018 .map(ToString::to_string)
1019 .collect(),
1020 })
1021}
1022
1023/// Honor a `--project`/`--team` CLI override on a cloned config so the
1024/// on-disk `tix.toml` stays untouched.
1025fn apply_project_override(config: &Config, tool: Tool, project: Option<&str>) -> Config {
1026 let Some(project) = project else {
1027 return config.clone();
1028 };
1029 let mut config = config.clone();
1030 match tool {
1031 Tool::GitLab => {
1032 let remote = config.export.gitlab.get_or_insert_with(Default::default);
1033 remote.project = Some(project.to_string());
1034 }
1035 Tool::Linear => {
1036 let remote = config.export.linear.get_or_insert_with(Default::default);
1037 remote.team = Some(project.to_string());
1038 }
1039 }
1040 config
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 #[allow(clippy::wildcard_imports)]
1046 use super::*;
1047 use crate::model::{Issue, Priority, Status};
1048 use std::fs;
1049 use tempfile::TempDir;
1050
1051 fn make_store() -> (TempDir, Store) {
1052 let tmp = TempDir::new().unwrap();
1053 let dir = tmp.path().join("issues");
1054 fs::create_dir_all(&dir).unwrap();
1055 let config_path = tmp.path().join("tix.toml");
1056 let mut config = crate::config::Config {
1057 issues_dir: dir.clone(),
1058 ..crate::config::Config::default()
1059 };
1060 config.export.gitlab = Some(crate::config::RemoteConfig {
1061 project: Some("acme/tix".to_string()),
1062 ..Default::default()
1063 });
1064 config.save(&config_path).unwrap();
1065 let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
1066 (
1067 tmp,
1068 Store {
1069 issues_dir: dir,
1070 config,
1071 },
1072 )
1073 }
1074
1075 #[test]
1076 fn the_preflight_line_warns_only_past_a_hundred_issues() {
1077 // The boundary itself: a hundred rows are quiet, a hundred and one are not.
1078 assert_eq!(
1079 preflight_line(100, 200, 100),
1080 "exporting 100 of 200 issue(s) to beads (100 unchanged)"
1081 );
1082 assert_eq!(
1083 preflight_line(101, 200, 99),
1084 "exporting 101 of 200 issue(s) to beads (99 unchanged) — this may take a while..."
1085 );
1086 // The incremental case that matters in practice: one issue going out of a
1087 // board of thousands says nothing about taking a while.
1088 assert_eq!(
1089 preflight_line(1, 1707, 1706),
1090 "exporting 1 of 1707 issue(s) to beads (1706 unchanged)"
1091 );
1092 }
1093
1094 #[test]
1095 fn dry_run_captures_the_report_without_invoking_the_tool() {
1096 let (tmp, store) = make_store();
1097 let _ = tmp;
1098 let mut issue = Issue {
1099 projects: None,
1100 id: None,
1101 number: 1,
1102 type_: None,
1103 title: "Replicate".to_string(),
1104 status: Status::new("todo"),
1105 labels: vec!["bug".to_string()],
1106 priority: Priority::High,
1107 assignee: Some("alice".to_string()),
1108 estimate: None,
1109 commit: None,
1110 gitlab_id: None,
1111 linear_id: None,
1112 mission: None,
1113 task: None,
1114 relationships: vec![],
1115 related_issues: vec![],
1116 files: vec![],
1117 cycles: None,
1118 milestones: None,
1119 reviewer: None,
1120 start_date: None,
1121 due_date: None,
1122 created: "2026-01-01".to_string(),
1123 updated: "2026-01-01".to_string(),
1124 filename: String::new(),
1125 body: "body".to_string(),
1126 history: crate::model::History::new(),
1127 has_retired_links_key: false,
1128 time_entries: vec![],
1129 parent: None,
1130 series: None,
1131 health: None,
1132 epics: None,
1133 extra_frontmatter: std::collections::BTreeMap::new(),
1134 archived: false,
1135 entered_at: None,
1136 source: None,
1137 };
1138 issue.filename = "alice/2026-01-01-replicate.md".to_string();
1139 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1140 fs::write(
1141 store.issues_dir.join(&issue.filename),
1142 issue.to_file_contents(),
1143 )
1144 .unwrap();
1145
1146 let report = run_core(&store.config, &store, Some("gitlab"), true, None).unwrap();
1147 assert!(
1148 report.contains("would create gitlab issue \"Replicate\""),
1149 "dry-run report must describe the planned create, got:\n{report}"
1150 );
1151 assert!(
1152 report.contains("dry run — gitlab CLI was not invoked"),
1153 "dry-run never invokes the binary, got:\n{report}"
1154 );
1155 }
1156
1157 #[test]
1158 fn run_core_missing_export_section_is_the_frozen_config_error() {
1159 let (tmp, store) = make_store();
1160 let _ = tmp;
1161 // Drop the gitlab config the helper seeded: no binary is needed for
1162 // the BAIL gate to fire before any capture.
1163 let mut bare = store.config.clone();
1164 bare.export.gitlab = None;
1165 let err = run_core(&bare, &store, Some("gitlab"), false, None).unwrap_err();
1166 assert_eq!(
1167 err.to_string(),
1168 "config error: no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml"
1169 );
1170 }
1171
1172 #[test]
1173 fn run_core_missing_target_is_an_invalid_input_error() {
1174 let (tmp, store) = make_store();
1175 let _ = tmp;
1176 let err = run_core(&store.config, &store, None, true, None).unwrap_err();
1177 assert!(
1178 matches!(err, TixError::InvalidInput(_)),
1179 "missing target must be a structured InvalidInput error, got {err}"
1180 );
1181 }
1182}