use std::path::{Path, PathBuf};
use crate::analytics::chart::{ChartExport, ChartProjection};
use crate::analytics::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use crate::analytics::events::{AnalyticsScope, Period, ScopeKind, cycle_scope, milestone_scope};
use crate::analytics::series::Mode;
use crate::cli::{AnalyticsCommand, AnalyticsScopeArgs};
use crate::commands::export::chart_document;
use crate::config::Config;
use crate::diagnostics::Diagnostic;
use crate::error::{Result, TixError};
use crate::ics;
use crate::model::validate_date;
use crate::planning::Planner;
use crate::storage::Store;
pub fn run(config: &Config, store: &Store, command: &AnalyticsCommand) -> Result<()> {
match command {
AnalyticsCommand::Burndown { args } => burndown(config, store, args),
AnalyticsCommand::Projection { args, as_of } => projection(config, store, args, as_of),
AnalyticsCommand::Export {
args,
as_of,
output,
} => export(config, store, args, as_of.as_deref(), output.as_deref()),
}
}
pub(crate) struct AnalyticsReport {
pub(crate) text: String,
pub(crate) document: ChartExport,
}
pub(crate) fn burndown_report(
config: &Config,
store: &Store,
args: &AnalyticsScopeArgs,
) -> Result<AnalyticsReport> {
let document = document(config, store, args, mode_of(args.mode.as_deref())?, None)?;
if document.is_empty() {
return Err(refusal(&document.series.diagnostics));
}
Ok(AnalyticsReport {
text: burndown_block(&document),
document,
})
}
pub(crate) fn projection_report(
config: &Config,
store: &Store,
args: &AnalyticsScopeArgs,
as_of: &str,
) -> Result<AnalyticsReport> {
let document = document(
config,
store,
args,
mode_of(args.mode.as_deref())?,
Some(as_of),
)?;
let Some(forecast) = document.projection.as_ref() else {
return Err(refuse(
"the projection verb asked for a forecast and the document carries none",
"report this: the request and the document disagree",
));
};
Ok(AnalyticsReport {
text: projection_block(&document, forecast),
document,
})
}
pub(crate) fn export_document(
config: &Config,
store: &Store,
args: &AnalyticsScopeArgs,
as_of: Option<&str>,
) -> Result<ChartExport> {
let Some(raw) = args.mode.as_deref() else {
return Err(refuse(
"`tix analytics export` needs an explicit `--mode`",
"pass `--mode count` or `--mode estimate`: the document states the mode it was \
folded in",
));
};
document(config, store, args, parse_mode(raw)?, as_of)
}
fn burndown(config: &Config, store: &Store, args: &AnalyticsScopeArgs) -> Result<()> {
let report = burndown_report(config, store, args)?;
print!("{}", report.text);
render_findings(&report.document, None);
Ok(())
}
fn projection(
config: &Config,
store: &Store,
args: &AnalyticsScopeArgs,
as_of: &str,
) -> Result<()> {
let report = projection_report(config, store, args, as_of)?;
print!("{}", report.text);
render_findings(&report.document, report.document.projection.as_ref());
Ok(())
}
fn export(
config: &Config,
store: &Store,
args: &AnalyticsScopeArgs,
as_of: Option<&str>,
output: Option<&str>,
) -> Result<()> {
let document = export_document(config, store, args, as_of)?;
render_findings(&document, document.projection.as_ref());
let json = document.to_json_pretty()?;
match output {
Some(path) => {
ics::write_to_path(Path::new(path), &json)?;
println!("{path}");
}
None => print!("{json}"),
}
Ok(())
}
fn document(
config: &Config,
store: &Store,
args: &AnalyticsScopeArgs,
mode: Mode,
as_of: Option<&str>,
) -> Result<ChartExport> {
let scope = resolve_scope(config, store, args)?;
let unit = args.unit.as_deref();
if mode == Mode::Estimate && unit.is_none() {
return Err(refusal(&[AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingConversion,
scope.id.clone(),
"estimate mode needs an explicit unit conversion, and no `--unit` was named",
)]));
}
let forecast = match as_of {
Some(as_of) => {
let as_of = date_arg("--as-of", as_of)?;
let Some(end) = scope.period_end() else {
return Err(refusal(&[AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::ProjectionUnknown,
scope.id.clone(),
format!(
"the {} scope `{}` declares no planning end date, so a forecast has no \
boundary to be drawn against",
scope.kind, scope.id
),
)
.on(as_of)
.with_remedy(
"pass `--to <YYYY-MM-DD>` to name the planning end the forecast is drawn \
against",
)]));
};
Some((as_of, end.to_string()))
}
None => None,
};
chart_document(
config,
store,
&scope,
mode,
unit,
forecast
.as_ref()
.map(|(as_of, end)| (as_of.as_str(), end.as_str())),
)
}
fn resolve_scope(
config: &Config,
store: &Store,
args: &AnalyticsScopeArgs,
) -> Result<AnalyticsScope> {
let kind = ScopeKind::parse(&args.scope).ok_or_else(|| {
refuse(
format!(
"unknown --scope {:?}: the scope kinds are `cycle` and `milestone`",
args.scope
),
"pass `--scope cycle` or `--scope milestone`",
)
})?;
let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."));
let planner = Planner::from_config(config.clone(), root);
let mut scope = match kind {
ScopeKind::Cycle => cycle_scope(&planner, &args.id)?,
ScopeKind::Milestone => milestone_scope(&planner, &args.id)?,
};
let start = match args.from.as_deref() {
Some(raw) => Some(date_arg("--from", raw)?),
None => scope.period.start.clone(),
};
let end = match args.to.as_deref() {
Some(raw) => Some(date_arg("--to", raw)?),
None => scope.period.end.clone(),
};
match Period::new(start, end) {
Ok(period) => scope.period = period,
Err(TixError::Config(message)) => {
return Err(refuse(
message,
"pass `--from` on or before `--to`, each a `YYYY-MM-DD` calendar date",
));
}
Err(other) => return Err(other),
}
Ok(scope)
}
fn mode_of(raw: Option<&str>) -> Result<Mode> {
match raw {
Some(raw) => parse_mode(raw),
None => Ok(Mode::Count),
}
}
fn parse_mode(raw: &str) -> Result<Mode> {
Mode::parse(raw).ok_or_else(|| {
refuse(
format!("unknown --mode {raw:?}: the modes are `count` and `estimate`"),
"pass `--mode count` or `--mode estimate`",
)
})
}
fn date_arg(field: &str, raw: &str) -> Result<String> {
let date = raw.trim();
if !validate_date(date) {
return Err(refuse(
format!("invalid_period: `{field}` is `{raw}`, not a `YYYY-MM-DD` calendar date"),
"pass a canonical `YYYY-MM-DD` calendar date, e.g. `2026-01-05`",
));
}
Ok(date.to_string())
}
fn scope_lines(document: &ChartExport) -> Vec<String> {
let scope = &document.scope;
vec![
format!("scope: {}", scope.kind),
format!("id: {}", scope.id),
format!(
"period: {}..{}",
scope.period.start.as_deref().unwrap_or("open"),
scope.period.end.as_deref().unwrap_or("open")
),
format!("snapshot: {}", scope.snapshot_id.as_deref().unwrap_or("-")),
format!("mode: {}", document.mode),
format!("unit: {}", document.unit.as_deref().unwrap_or("-")),
]
}
fn burndown_block(document: &ChartExport) -> String {
let coverage = document.coverage;
let mut lines = scope_lines(document);
lines.push(format!("formula: {}", document.formula_version));
lines.push(format!("days: {}", document.series.points.len()));
lines.push(format!(
"coverage: known={} unknown={} coverage_limited={}",
coverage.known, coverage.unknown, coverage.coverage_limited
));
lines.push(format!("diagnostics: {}", finding_count(document, None)));
lines.push(String::new());
lines
.push("date\tscope_total\tremaining\tcompleted\tscope_added\tcompletions\tcoverage".into());
for point in &document.series.points {
lines.push(format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
point.date,
number(point.scope_total),
number(point.remaining),
number(point.completed),
number(point.scope_added),
number(point.completions),
point.coverage,
));
}
join_block(lines)
}
fn projection_block(document: &ChartExport, forecast: &ChartProjection) -> String {
let mut lines = scope_lines(document);
lines.push(format!("formula: {}", forecast.formula_version));
lines.push(format!("as_of: {}", forecast.as_of));
lines.push(format!("end_date: {}", forecast.end_date));
lines.push(format!("status: {}", forecast.status));
lines.push(format!("remaining: {}", number(forecast.remaining)));
lines.push(format!("observed_rate: {}", number(forecast.observed_rate)));
lines.push(format!(
"projected_completion: {}",
forecast.projected_completion.as_deref().unwrap_or("-")
));
lines.push(format!(
"meets_end_date: {}",
number(forecast.meets_end_date)
));
lines.push(format!(
"diagnostics: {}",
finding_count(document, Some(forecast))
));
join_block(lines)
}
fn join_block(lines: Vec<String>) -> String {
let mut block = lines.join("\n");
block.push('\n');
block
}
fn finding_count(document: &ChartExport, forecast: Option<&ChartProjection>) -> usize {
document.series.diagnostics.len()
+ forecast.map_or(0, |forecast| forecast.diagnostics.len())
+ document.diagnostics.len()
}
fn render_findings(document: &ChartExport, forecast: Option<&ChartProjection>) {
for finding in &document.series.diagnostics {
eprintln!("diagnostic: {}", finding.render());
}
if let Some(forecast) = forecast {
for finding in &forecast.diagnostics {
eprintln!("diagnostic: {}", finding.render());
}
}
for finding in &document.diagnostics {
eprintln!("diagnostic: {}", finding.render());
}
}
fn refusal(findings: &[AnalyticsDiagnostic]) -> TixError {
if findings.is_empty() {
return refuse(
"the record answers no day for this scope, so there is no series to report",
"widen the period with `--from`/`--to`, or select a scope the record has \
observations for",
);
}
let blocks: Vec<String> = findings
.iter()
.map(|finding| {
let mut block = Diagnostic::error("E-tix-analytics", finding.message.clone())
.with_note(format!("{}: {}", finding.kind.as_str(), finding.scope));
if let Some(date) = finding.date.as_deref() {
block = block.with_note(format!("date: {date}"));
}
if let Some(event_id) = finding.event_id.as_deref() {
block = block.with_note(format!("event: {event_id}"));
}
match finding.remedy.as_deref() {
Some(remedy) => block.with_help(remedy).render(),
None => block.render(),
}
})
.collect();
TixError::Refused(blocks.join("\n"))
}
fn refuse(reason: impl Into<String>, remedy: impl Into<String>) -> TixError {
TixError::Refused(
Diagnostic::error("E-tix-analytics", reason)
.with_help(remedy)
.render(),
)
}
fn number<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "-".to_string(), |value| value.to_string())
}