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::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};
use crate::time_tracking::aggregate::TimeSnapshot;
use crate::time_tracking::filters::TimeFilter;
use crate::time_tracking::portable::PortableTimeDocument;
use crate::views::portable::{PortableDocument, PortableRemoteTargets};
pub fn run(
config: &Config,
store: &Store,
target: &str,
dry_run: bool,
project_override: Option<&str>,
cycle: Option<&str>,
output: Option<&str>,
) -> Result<()> {
if target == "ics" {
return run_ics(config, store, cycle, output);
}
if target == "views" {
return run_views(config, output);
}
if target == "health" {
return run_health(config, output);
}
if target == "roadmap" {
return run_roadmap(config, output);
}
if target == "capacity" {
return run_capacity(config, store, cycle, output);
}
if target == "time" {
return run_time(config, store, output);
}
if target == "chart" {
return run_chart(config, store, cycle, output);
}
let report = run_core(config, store, Some(target), dry_run, project_override)?;
print!("{report}");
Ok(())
}
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
}
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())
})?;
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(String::from_utf8(report).expect("sync reports contain only UTF-8 text"))
}
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 {
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 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}"
);
}
}