Skip to main content

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}