use std::collections::BTreeSet;
use crate::analytics::chart::ChartExport;
use crate::analytics::events::{AnalyticsScope, Period};
use crate::analytics::series::{Mode, ScopeSnapshot};
use crate::analytics::units::estimate_series;
use crate::capacity::portable::PortableCapacityDocument;
use crate::cli::ExitCode;
use crate::config::Config;
use crate::cycle_schedule::identity::CycleReference;
use crate::error::{Result, TixError};
use crate::health::diagnostics::{HealthDiagnostic, HealthDiagnosticKind, RemoteTarget};
use crate::health::events::{HealthScope, project};
use crate::health::policy::HealthPolicy;
use crate::health::portable::{PortableHealth, PortableHealthDiagnostic};
use crate::ics::{self, Event};
use crate::planning::Planner;
use crate::roadmap::RoadmapFilter;
use crate::roadmap::portable::PortableRoadmap;
use crate::storage::Store;
use crate::sync::{self, MappedHealth, Tool, beads};
use crate::time_tracking::aggregate::TimeSnapshot;
use crate::time_tracking::filters::TimeFilter;
use crate::time_tracking::portable::PortableTimeDocument;
use crate::views::portable::{PortableDocument, PortableRemoteTargets};
#[allow(clippy::too_many_arguments)]
pub fn run(
config: &Config,
store: &Store,
target: &str,
dry_run: bool,
project_override: Option<&str>,
cycle: Option<&str>,
output: Option<&str>,
verbose: bool,
) -> Result<ExitCode> {
if target == "ics" {
run_ics(config, store, cycle, output)?;
return Ok(ExitCode::Success);
}
if target == "views" {
run_views(config, output)?;
return Ok(ExitCode::Success);
}
if target == "health" {
run_health(config, output)?;
return Ok(ExitCode::Success);
}
if target == "roadmap" {
run_roadmap(config, output)?;
return Ok(ExitCode::Success);
}
if target == "capacity" {
run_capacity(config, store, cycle, output)?;
return Ok(ExitCode::Success);
}
if target == "time" {
run_time(config, store, output)?;
return Ok(ExitCode::Success);
}
if target == "chart" {
run_chart(config, store, cycle, output)?;
return Ok(ExitCode::Success);
}
if target == "beads" {
run_beads(config, store, dry_run, output, verbose)?;
return Ok(ExitCode::Success);
}
let outcome = run_remote_core(config, store, target, dry_run, project_override)?;
print!("{}", outcome.report);
for diagnostic in &outcome.diagnostics {
eprintln!("{diagnostic}");
}
Ok(if outcome.diagnostics.is_empty() {
ExitCode::Success
} else {
ExitCode::UsageConfig
})
}
#[derive(Debug)]
struct RemoteExportOutcome {
report: String,
diagnostics: Vec<String>,
}
fn run_ics(
config: &Config,
store: &Store,
cycle: Option<&str>,
output: Option<&str>,
) -> Result<()> {
let cycle_name = cycle.ok_or_else(|| TixError::InvalidInput("missing --cycle".into()))?;
let root =
Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
let planner = Planner::from_config(config.clone(), root);
let cycle = planner
.cycle(cycle_name)
.ok_or_else(|| TixError::NotFound(format!("cycle `{cycle_name}`")))?;
let cycle_start = cycle.start_date.clone().unwrap_or_else(crate::model::today);
if cycle.start_date.is_none() || cycle.due_date.is_none() {
eprintln!(
"{}",
crate::diagnostics::Diagnostic::note(
"N-tix-export",
format!("cycle `{cycle_name}` has incomplete dates; DTSTART fallback was applied")
)
.render_auto()
);
}
let cycle_end = cycle
.due_date
.as_deref()
.and_then(ics::plus_one_day)
.and_then(|s| ics::ymd_to_ics(&s));
let mut matching_issues: Vec<_> = store
.list()?
.into_iter()
.filter(|issue| {
issue
.cycles
.as_ref()
.is_some_and(|cycles| cycles.iter().any(|c| c == cycle_name))
})
.collect();
matching_issues.sort_by_key(|i| i.number);
let cycle_start_ics = ics::ymd_to_ics(&cycle_start).ok_or_else(|| {
TixError::InvalidInput(format!("invalid cycle start date `{cycle_start}`"))
})?;
let mut events = vec![Event {
uid: format!(
"cycle-{}-{}-{}@tix",
crate::slug::slugify(cycle_name),
cycle.start_date.as_deref().unwrap_or("none"),
cycle.due_date.as_deref().unwrap_or("none")
),
summary: cycle_name.to_string(),
description: Some(format!(
"{} / {} issue(s)",
cycle.description.clone().unwrap_or_default(),
matching_issues.len()
)),
dtstart: Some(cycle_start_ics),
dtend: cycle_end,
}];
for issue in matching_issues {
let start = issue
.start_date
.clone()
.or_else(|| cycle.start_date.clone())
.unwrap_or_else(crate::model::today);
let end = issue
.due_date
.as_deref()
.or(cycle.due_date.as_deref())
.and_then(ics::plus_one_day)
.and_then(|s| ics::ymd_to_ics(&s));
events.push(Event {
uid: format!("issue-{}-{}@tix", issue.number, cycle_name),
summary: format!("[{}] {}", issue.status.display(), issue.title),
description: (!issue.body.trim().is_empty()).then_some(issue.body.clone()),
dtstart: ics::ymd_to_ics(&start),
dtend: end,
});
}
let calendar = ics::write_vcalendar(&events)?;
if let Some(path) = output {
ics::write_to_path(std::path::Path::new(path), &calendar)?;
println!("{path}");
} else {
print!("{calendar}");
}
Ok(())
}
pub fn run_health(config: &Config, output: Option<&str>) -> Result<()> {
let document = run_health_core(config)?;
for diagnostic in &document.diagnostics {
eprintln!("diagnostic: {}", diagnostic.to_diagnostic().render());
}
let json = document.to_json_pretty()?;
match output {
Some(path) => {
ics::write_to_path(std::path::Path::new(path), &json)?;
println!("{path}");
}
None => print!("{json}"),
}
Ok(())
}
pub fn run_roadmap(config: &Config, output: Option<&str>) -> Result<()> {
let document = run_roadmap_core(config)?;
for diagnostic in &document.diagnostics {
eprintln!("diagnostic: {}", diagnostic.render());
}
let json = document.to_json_pretty()?;
match output {
Some(path) => {
ics::write_to_path(std::path::Path::new(path), &json)?;
println!("{path}");
}
None => print!("{json}"),
}
Ok(())
}
pub fn run_roadmap_core(config: &Config) -> Result<PortableRoadmap> {
let store = Store::open(config)?;
let issues = store.list_all()?;
let sources = crate::commands::roadmap::build_sources(config, &store, &issues)?;
let document = PortableRoadmap::build(config, &sources, &RoadmapFilter::default())?
.with_remote_mappings(config);
Ok(document)
}
pub fn run_capacity(
config: &Config,
store: &Store,
cycle: Option<&str>,
output: Option<&str>,
) -> Result<()> {
let document = run_capacity_core(config, store, cycle)?;
for diagnostic in &document.diagnostics {
eprintln!("diagnostic: {}", diagnostic.render());
}
let json = document.to_json_pretty()?;
match output {
Some(path) => {
ics::write_to_path(std::path::Path::new(path), &json)?;
println!("{path}");
}
None => print!("{json}"),
}
Ok(())
}
pub fn run_capacity_core(
config: &Config,
store: &Store,
cycle: Option<&str>,
) -> Result<PortableCapacityDocument> {
let root =
Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
let (index, snapshot) = crate::commands::capacity::snapshot_for(config, &root, store)?;
match cycle {
Some(reference) => {
PortableCapacityDocument::build_for_cycle(config, &root, &index, &snapshot, reference)
}
None => PortableCapacityDocument::build(config, &root, &index, &snapshot),
}
}
pub fn run_time(config: &Config, store: &Store, output: Option<&str>) -> Result<()> {
let document = run_time_core(config, store)?;
for diagnostic in &document.diagnostics {
eprintln!("diagnostic: {}", diagnostic.render());
}
let json = document.to_json_pretty()?;
match output {
Some(path) => {
ics::write_to_path(std::path::Path::new(path), &json)?;
println!("{path}");
}
None => print!("{json}"),
}
Ok(())
}
pub fn run_time_core(config: &Config, store: &Store) -> Result<PortableTimeDocument> {
let snapshot = TimeSnapshot::of(&store.list()?);
PortableTimeDocument::build(config, &snapshot, &TimeFilter::new())
}
pub fn run_chart(
config: &Config,
store: &Store,
cycle: Option<&str>,
output: Option<&str>,
) -> Result<()> {
let document = run_chart_core(config, store, cycle)?;
for diagnostic in &document.diagnostics {
eprintln!("diagnostic: {}", diagnostic.render());
}
let json = document.to_json_pretty()?;
match output {
Some(path) => {
ics::write_to_path(std::path::Path::new(path), &json)?;
println!("{path}");
}
None => print!("{json}"),
}
Ok(())
}
pub fn run_chart_core(config: &Config, store: &Store, cycle: Option<&str>) -> Result<ChartExport> {
let reference = cycle.ok_or_else(|| TixError::InvalidInput("missing --cycle".into()))?;
let scope = cycle_scope_for(config, store, reference)?;
chart_document(config, store, &scope, Mode::Count, None, None)
}
pub fn cycle_scope_for(config: &Config, store: &Store, reference: &str) -> Result<AnalyticsScope> {
let root =
Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
let index = crate::capacity::schedule_index(config, &root)?;
let name = match crate::capacity::resolve_cycle(&index, reference) {
CycleReference::Generated { name, .. } | CycleReference::Legacy { name } => name,
CycleReference::Unknown { name } => {
return Err(TixError::NotFound(format!("cycle `{name}`")));
}
};
let period = cycle_period(config, &root, &name)?;
AnalyticsScope::cycle(name, period)
}
fn cycle_period(config: &Config, root: &std::path::Path, name: &str) -> Result<Period> {
if let Some(record) = crate::cycle_store::CycleStore::new(root)
.list()?
.into_iter()
.find(|record| record.name == name)
{
return Period::new(record.start_date, record.due_date);
}
match config.cycles.get(name) {
Some(cycle) => Period::new(cycle.start_date.clone(), cycle.due_date.clone()),
None => Ok(Period::default()),
}
}
pub fn chart_document(
config: &Config,
store: &Store,
scope: &AnalyticsScope,
mode: Mode,
unit: Option<&str>,
projection: Option<(&str, &str)>,
) -> Result<ChartExport> {
let root =
Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
let index = crate::capacity::schedule_index(config, &root)?;
let issues = store.list_all()?;
let snapshot = ScopeSnapshot::build(config, &index, &issues, scope);
let series = match mode {
Mode::Count => snapshot.series(Mode::Count),
Mode::Estimate => {
let unit = unit.ok_or_else(|| {
TixError::InvalidInput("estimate mode needs a unit (--unit)".into())
})?;
estimate_series(&snapshot, &issues, config, unit)
}
};
let projection = match projection {
Some((as_of, end_date)) => Some(crate::analytics::projection::project(
&series, as_of, end_date,
)?),
None => None,
};
let targets = configured_remote_targets(config);
Ok(ChartExport::build(&series, projection.as_ref(), &targets))
}
fn configured_remote_targets(config: &Config) -> Vec<&'static str> {
let mut targets = Vec::new();
if config.export.gitlab.is_some() {
targets.push("gitlab");
}
if config.export.linear.is_some() {
targets.push("linear");
}
targets
}
fn run_beads(
config: &Config,
store: &Store,
dry_run: bool,
output: Option<&str>,
verbose: bool,
) -> Result<()> {
if let Some(path) = output {
let plan = beads_plan(config, store)?;
if verbose {
for diagnostic in &plan.diagnostics {
eprintln!("{diagnostic}");
}
}
ics::write_to_path(std::path::Path::new(path), &beads::to_jsonl(&plan.rows)?)?;
println!("{path}");
return Ok(());
}
let report = run_beads_core(
config,
store,
ExportOptions {
dry_run,
all: false,
verbose,
},
)?;
if dry_run && report.trim().is_empty() {
eprintln!("nothing changed since the last export (pass --all to send everything)");
}
print!("{report}");
Ok(())
}
fn beads_plan(config: &Config, store: &Store) -> Result<beads::BeadsPlan> {
let resolution = crate::workflow::resolution(config, config.default_config(), "default");
let issues = store.list()?;
let relationships = store.all_relationships()?;
Ok(beads::plan(
&config.beads.id_prefix,
&resolution,
&issues,
&relationships,
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions {
pub dry_run: bool,
pub all: bool,
pub verbose: bool,
}
const SLOW_EXPORT_THRESHOLD: usize = 100;
fn preflight_line(sending: usize, total: usize, unchanged: usize) -> String {
let caveat = if sending > SLOW_EXPORT_THRESHOLD {
" — this may take a while..."
} else {
""
};
format!("exporting {sending} of {total} issue(s) to beads ({unchanged} unchanged){caveat}")
}
pub fn run_beads_core(config: &Config, store: &Store, options: ExportOptions) -> Result<String> {
let ExportOptions {
dry_run,
all,
verbose,
} = options;
let root =
Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
let beads::BeadsPlan { rows, diagnostics } = beads_plan(config, store)?;
let workspace = beads::workspace_identity(config);
let mut state = beads::load_state(&root);
let workspace_changed = !state.exported.is_empty() && state.workspace != workspace;
if workspace_changed {
state.exported.clear();
}
let beads::BeadsPayload {
rows: payload,
fingerprints,
unchanged,
} = beads::select_payload(rows, &state.exported, all)?;
let sent: BTreeSet<u32> = payload
.iter()
.filter_map(|row| beads::number_from_id(&row.id, &config.beads.id_prefix))
.collect();
let reported: Vec<&beads::BeadsDiagnostic> = diagnostics
.iter()
.filter(|diagnostic| {
diagnostic
.number
.is_none_or(|number| sent.contains(&number))
})
.collect();
if workspace_changed {
eprintln!(
"the beads workspace changed since the last export; the export watermark was reset"
);
}
if verbose {
for diagnostic in &reported {
eprintln!("{diagnostic}");
}
}
if dry_run {
return beads::to_jsonl(&payload);
}
let total = payload.len() + unchanged;
if payload.is_empty() {
return Ok(format!(
"nothing to export: all {unchanged} issue(s) are unchanged since the last export\n"
));
}
eprintln!("{}", preflight_line(payload.len(), total, unchanged));
let mut report = beads::push(&crate::sync::ProcessRunner, &config.beads.binary, &payload)?;
for (id, fingerprint) in fingerprints {
state.exported.insert(id, fingerprint);
}
state.workspace = workspace;
beads::save_state(&root, &state)?;
let hidden = if verbose || reported.is_empty() {
String::new()
} else {
"; pass --verbose to list them".to_string()
};
report.push_str(&format!(
"exported {} of {} issue(s) to beads ({} unchanged, {} diagnostic(s) — local data beads \
cannot hold{hidden})\n",
payload.len(),
total,
unchanged,
reported.len()
));
Ok(report)
}
pub fn run_views(config: &Config, output: Option<&str>) -> Result<()> {
let document = run_views_core(config)?;
for diagnostic in document.all_diagnostics() {
eprintln!("diagnostic: {}", diagnostic.render());
}
let json = document.to_json_pretty()?;
match output {
Some(path) => {
ics::write_to_path(std::path::Path::new(path), &json)?;
println!("{path}");
}
None => print!("{json}"),
}
Ok(())
}
pub fn run_views_core(config: &Config) -> Result<PortableDocument> {
let mut document =
PortableDocument::from_registry(&config.views, &project_directory(), None, None);
document.remote = PortableRemoteTargets::from_config(config);
Ok(document)
}
pub fn run_health_core(config: &Config) -> Result<PortableHealth> {
let store = Store::open(config)?;
let issues = store.list_all()?;
let root =
Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
let planner = Planner::from_config(config.clone(), root);
let cycles = planner.cycle_names();
let milestones: Vec<String> = planner
.list_milestones()?
.into_iter()
.map(|milestone| milestone.name)
.collect();
let policy = HealthPolicy::v1();
let mut document = PortableHealth::build(&issues, &cycles, &milestones, &policy);
let targets: Vec<(Tool, RemoteTarget)> = [
(
config.export.gitlab.is_some(),
Tool::GitLab,
RemoteTarget::GitLab,
),
(
config.export.linear.is_some(),
Tool::Linear,
RemoteTarget::Linear,
),
]
.into_iter()
.filter_map(|(configured, tool, target)| configured.then_some((tool, target)))
.collect();
if targets.is_empty() {
return Ok(document);
}
for issue in &issues {
let view = project(issue, &policy);
let scope = HealthScope::of_issue(issue.number);
for (tool, target) in &targets {
let MappedHealth::Unsupported(diagnostic) =
sync::map_health_to_remote(*tool, &scope, view.state)
else {
continue;
};
let wire = HealthDiagnostic::new(
HealthDiagnosticKind::UnsupportedRemoteField,
scope.clone(),
diagnostic.message.clone(),
)
.referencing(diagnostic.relation_id.clone())
.at_field(diagnostic.field.clone())
.for_target(*target)
.carrying(view.state.as_str());
document
.diagnostics
.push(PortableHealthDiagnostic::from_diagnostic(&wire));
}
}
document.diagnostics.sort_by(|left, right| {
(&left.kind, &left.scope, &left.message).cmp(&(&right.kind, &right.scope, &right.message))
});
document
.diagnostics
.dedup_by(|left, right| left.same_as(right));
Ok(document)
}
fn project_directory() -> BTreeSet<String> {
BTreeSet::new()
}
pub fn run_core(
config: &Config,
store: &Store,
target: Option<&str>,
dry_run: bool,
project_override: Option<&str>,
) -> Result<String> {
let target = target.ok_or_else(|| {
TixError::InvalidInput("missing --target — use gitlab or linear".to_string())
})?;
if target == "beads" {
return run_beads_core(
config,
store,
ExportOptions {
dry_run,
all: false,
verbose: false,
},
);
}
let outcome = run_remote_core(config, store, target, dry_run, project_override)?;
let mut text = outcome.report;
for diagnostic in outcome.diagnostics {
text.push_str(&diagnostic);
text.push('\n');
}
Ok(text)
}
fn run_remote_core(
config: &Config,
store: &Store,
target: &str,
dry_run: bool,
project_override: Option<&str>,
) -> Result<RemoteExportOutcome> {
let tool = Tool::from_target(target)?;
let config = apply_project_override(config, tool, project_override);
let mut report: Vec<u8> = Vec::new();
let outcome = sync::export(store, tool, &config, dry_run, &mut report)?;
let handled = outcome.handled;
if handled == 0 {
report.extend_from_slice(b"nothing to export\n");
}
Ok(RemoteExportOutcome {
report: String::from_utf8(report).expect("sync reports contain only UTF-8 text"),
diagnostics: outcome
.diagnostics
.iter()
.map(ToString::to_string)
.collect(),
})
}
fn apply_project_override(config: &Config, tool: Tool, project: Option<&str>) -> Config {
let Some(project) = project else {
return config.clone();
};
let mut config = config.clone();
match tool {
Tool::GitLab => {
let remote = config.export.gitlab.get_or_insert_with(Default::default);
remote.project = Some(project.to_string());
}
Tool::Linear => {
let remote = config.export.linear.get_or_insert_with(Default::default);
remote.team = Some(project.to_string());
}
}
config
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.export.gitlab = Some(crate::config::RemoteConfig {
project: Some("acme/tix".to_string()),
..Default::default()
});
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
#[test]
fn the_preflight_line_warns_only_past_a_hundred_issues() {
assert_eq!(
preflight_line(100, 200, 100),
"exporting 100 of 200 issue(s) to beads (100 unchanged)"
);
assert_eq!(
preflight_line(101, 200, 99),
"exporting 101 of 200 issue(s) to beads (99 unchanged) — this may take a while..."
);
assert_eq!(
preflight_line(1, 1707, 1706),
"exporting 1 of 1707 issue(s) to beads (1706 unchanged)"
);
}
#[test]
fn dry_run_captures_the_report_without_invoking_the_tool() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
projects: None,
id: None,
number: 1,
type_: None,
title: "Replicate".to_string(),
status: Status::new("todo"),
labels: vec!["bug".to_string()],
priority: Priority::High,
assignee: Some("alice".to_string()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".to_string(),
updated: "2026-01-01".to_string(),
filename: String::new(),
body: "body".to_string(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
issue.filename = "alice/2026-01-01-replicate.md".to_string();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
let report = run_core(&store.config, &store, Some("gitlab"), true, None).unwrap();
assert!(
report.contains("would create gitlab issue \"Replicate\""),
"dry-run report must describe the planned create, got:\n{report}"
);
assert!(
report.contains("dry run — gitlab CLI was not invoked"),
"dry-run never invokes the binary, got:\n{report}"
);
}
#[test]
fn run_core_missing_export_section_is_the_frozen_config_error() {
let (tmp, store) = make_store();
let _ = tmp;
let mut bare = store.config.clone();
bare.export.gitlab = None;
let err = run_core(&bare, &store, Some("gitlab"), false, None).unwrap_err();
assert_eq!(
err.to_string(),
"config error: no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml"
);
}
#[test]
fn run_core_missing_target_is_an_invalid_input_error() {
let (tmp, store) = make_store();
let _ = tmp;
let err = run_core(&store.config, &store, None, true, None).unwrap_err();
assert!(
matches!(err, TixError::InvalidInput(_)),
"missing target must be a structured InvalidInput error, got {err}"
);
}
}