use std::collections::HashSet;
use std::io::IsTerminal;
use super::parse_csv_list;
use crate::cli::{ExitCode, InitiativeCommand, RoadmapCommand};
use crate::config::{Config, InitiativeConfiguration, InitiativeId};
use crate::error::{Result, TixError};
use crate::model::{Issue, Priority};
use crate::projects::Patch;
use crate::roadmap::RoadmapDiagnostic;
use crate::roadmap::filter::RoadmapFilter;
use crate::roadmap::projection::{
self, ProgressValue, RoadmapProjection, RoadmapRow, RoadmapSources, SummaryContribution,
};
use crate::roadmap::registry::{self, AssociationKind, InitiativeEdit, NewInitiative};
use crate::storage::Store;
pub fn run_initiative(
config: &mut Config,
store: &Store,
command: &InitiativeCommand,
) -> Result<ExitCode> {
match command {
InitiativeCommand::Create {
name,
outcome,
owner,
status,
priority,
labels,
target_date,
projects,
epics,
milestones,
} => create(
config,
store,
name,
outcome.as_deref(),
owner.as_deref(),
status.as_deref(),
priority.as_deref(),
labels.as_deref(),
target_date.as_deref(),
projects.as_deref(),
epics.as_deref(),
milestones.as_deref(),
),
InitiativeCommand::Edit {
reference,
name,
outcome,
owner,
status,
priority,
labels,
target_date,
projects,
epics,
milestones,
} => edit(
config,
store,
reference,
name.as_deref(),
outcome.as_deref(),
owner.as_deref(),
status.as_deref(),
priority.as_deref(),
labels.as_deref(),
target_date.as_deref(),
projects.as_deref(),
epics.as_deref(),
milestones.as_deref(),
),
InitiativeCommand::List => {
for line in initiative_listing_lines(config) {
println!("{line}");
}
Ok(ExitCode::Success)
}
InitiativeCommand::Show { reference } => show(config, reference),
InitiativeCommand::Delete { reference, yes } => delete(config, store, reference, *yes),
InitiativeCommand::Associate {
reference,
project,
epic,
milestone,
} => associate_or_dereference(
config,
store,
reference,
"associate",
project.as_deref(),
epic.as_deref(),
milestone.as_deref(),
),
InitiativeCommand::Dereference {
reference,
project,
epic,
milestone,
} => associate_or_dereference(
config,
store,
reference,
"dereference",
project.as_deref(),
epic.as_deref(),
milestone.as_deref(),
),
}
}
#[allow(clippy::too_many_arguments)]
fn create(
config: &mut Config,
store: &Store,
name: &str,
outcome: Option<&str>,
owner: Option<&str>,
status: Option<&str>,
priority: Option<&str>,
labels: Option<&str>,
target_date: Option<&str>,
projects: Option<&str>,
epics: Option<&str>,
milestones: Option<&str>,
) -> Result<ExitCode> {
let new = NewInitiative {
name: name.to_string(),
outcome: trim_optional(outcome),
owner: trim_optional(owner),
status: trim_optional(status),
priority: priority
.map(str::trim)
.filter(|value| !value.is_empty())
.map(loose_priority),
labels: labels.map(parse_csv_list).unwrap_or_default(),
target_date: trim_optional(target_date),
projects: projects.map(parse_csv_list).unwrap_or_default(),
epics: epics.map(parse_csv_list).unwrap_or_default(),
milestones: milestones.map(parse_csv_list).unwrap_or_default(),
extensions: crate::roadmap::registry::InitiativeExtensions::default(),
};
let path = crate::commands::views::config_path(store);
let id = registry::create_initiative(config, &path, &new)?;
println!("Created {} ({id})", new.name);
Ok(ExitCode::Success)
}
#[allow(clippy::too_many_arguments)]
fn edit(
config: &mut Config,
store: &Store,
reference: &str,
name: Option<&str>,
outcome: Option<&str>,
owner: Option<&str>,
status: Option<&str>,
priority: Option<&str>,
labels: Option<&str>,
target_date: Option<&str>,
projects: Option<&str>,
epics: Option<&str>,
milestones: Option<&str>,
) -> Result<ExitCode> {
if name.is_none()
&& outcome.is_none()
&& owner.is_none()
&& status.is_none()
&& priority.is_none()
&& labels.is_none()
&& target_date.is_none()
&& projects.is_none()
&& epics.is_none()
&& milestones.is_none()
{
return Err(initiative_edit_requires_a_field());
}
let id = registry::resolve_initiative_id(config, reference)?;
let edit = InitiativeEdit {
name: name.map_or(Patch::Unchanged, |value| Patch::Set(value.to_string())),
outcome: text_patch(outcome),
owner: text_patch(owner),
status: text_patch(status),
priority: priority.map_or(Patch::Unchanged, |value| {
if value.trim().is_empty() {
Patch::Clear
} else {
Patch::Set(loose_priority(value.trim()))
}
}),
labels: reference_patch(labels),
target_date: text_patch(target_date),
projects: reference_patch(projects),
epics: reference_patch(epics),
milestones: reference_patch(milestones),
};
let path = crate::commands::views::config_path(store);
registry::edit_initiative(config, &path, &id, &edit)?;
println!("Updated initiative {id}");
Ok(ExitCode::Success)
}
fn show(config: &Config, reference: &str) -> Result<ExitCode> {
let id = registry::resolve_initiative_id(config, reference)?;
let record = registry::get(config, &id).ok_or_else(|| unknown_initiative(reference))?;
for line in initiative_record_lines(&id, record) {
println!("{line}");
}
Ok(ExitCode::Success)
}
fn delete(config: &mut Config, store: &Store, reference: &str, yes: bool) -> Result<ExitCode> {
let id = registry::resolve_initiative_id(config, reference)?;
let record = registry::get(config, &id)
.cloned()
.ok_or_else(|| unknown_initiative(reference))?;
let mut confirmed = yes || !std::io::stdin().is_terminal();
if !confirmed {
eprint!("Delete initiative '{}' ({id})? [y/N] ", record.name);
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(TixError::Io)?;
confirmed = line.trim().eq_ignore_ascii_case("y");
if !confirmed {
eprintln!("aborted");
}
}
if !confirmed {
return Ok(ExitCode::Success);
}
let path = crate::commands::views::config_path(store);
registry::delete_initiative(config, &path, &id)?;
println!("Deleted initiative {} ({id})", record.name);
println!(
"Removed references (nothing cascades): {}",
reference_inventory(&record)
);
Ok(ExitCode::Success)
}
fn associate_or_dereference(
config: &mut Config,
store: &Store,
reference: &str,
verb: &str,
project: Option<&str>,
epic: Option<&str>,
milestone: Option<&str>,
) -> Result<ExitCode> {
let (kind, target) = association_target(verb, project, epic, milestone)?;
let id = registry::resolve_initiative_id(config, reference)?;
let path = crate::commands::views::config_path(store);
if verb == "associate" {
let changed = registry::associate(config, &path, id.as_str(), kind, &target)?;
if changed {
println!("Associated {kind} {target} with initiative {id}");
} else {
println!("Already associated: {kind} {target} with initiative {id}");
}
} else {
let changed = registry::dissociate(config, &path, id.as_str(), kind, &target)?;
if changed {
println!("Dereferenced {kind} {target} from initiative {id}");
} else {
println!("Not associated: {kind} {target} with initiative {id}");
}
}
Ok(ExitCode::Success)
}
pub fn run_roadmap(config: &Config, store: &Store, command: &RoadmapCommand) -> Result<ExitCode> {
match command {
RoadmapCommand::List {
from,
to,
owner,
status,
labels,
query,
} => list(
config,
store,
&build_filter(from, to, owner, status, labels, query),
),
RoadmapCommand::Show { reference } => show_roadmap(config, store, reference),
}
}
fn list(config: &Config, store: &Store, filter: &RoadmapFilter) -> Result<ExitCode> {
filter.validate()?;
let issues = store.list_all()?;
let sources = build_sources(config, store, &issues)?;
let projection = projection::project(config, &sources, filter)?;
if let Some(line) = roadmap_filter_line(filter) {
println!("{line}");
}
for line in roadmap_list_lines(&projection) {
println!("{line}");
}
Ok(ExitCode::Success)
}
fn show_roadmap(config: &Config, store: &Store, reference: &str) -> Result<ExitCode> {
let id = registry::resolve_initiative_id(config, reference)?;
let issues = store.list_all()?;
let sources = build_sources(config, store, &issues)?;
let projection = projection::project(config, &sources, &RoadmapFilter::default())?;
let row = projection
.initiatives
.iter()
.find(|row| row.id == id.as_str())
.ok_or_else(|| unknown_initiative(reference))?;
for line in roadmap_row_lines(row) {
println!("{line}");
}
Ok(ExitCode::Success)
}
fn build_filter(
from: &Option<String>,
to: &Option<String>,
owner: &Option<String>,
status: &Option<String>,
labels: &[String],
query: &Option<String>,
) -> RoadmapFilter {
RoadmapFilter {
period_from: from.clone(),
period_to: to.clone(),
owner: owner.clone(),
status: status.clone(),
labels: labels.to_vec(),
query: query.clone(),
}
}
pub fn build_sources<'a>(
config: &Config,
store: &Store,
issues: &'a [Issue],
) -> Result<RoadmapSources<'a>> {
let policy = config.health.policy()?;
let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| store.issues_dir.clone());
let planner = crate::planning::Planner::from_config(config.clone(), root);
let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
let overdue: HashSet<u32> = planner
.overdue_batch(store, &crate::model::today())
.iter()
.map(|issue| issue.number)
.collect();
let assigned = crate::projects::assigned_keys(issues);
let (graph_issues, done_column) = store.graph_source()?;
let views: Vec<crate::relationships::IssueView<'_>> =
graph_issues.iter().map(Issue::issue_view).collect();
let graph = crate::relationships::RelationshipGraph::new(&views, &done_column);
let blocked: HashSet<u32> = graph_issues
.iter()
.filter(|issue| graph.blocked_state(issue.number).is_blocked)
.map(|issue| issue.number)
.collect();
let consumed = crate::roadmap::RoadmapConsumed {
targets,
done_column,
assigned,
overdue,
blocked,
};
Ok(RoadmapSources::new(config, issues, &policy, consumed))
}
#[must_use]
pub fn initiative_listing_lines(config: &Config) -> Vec<String> {
registry::ids(config)
.map(|id| {
let record = &config.initiatives[id];
let date = record.target_date.as_deref().unwrap_or("-");
let status = record.status.as_deref().unwrap_or("-");
format!("{id}\t{}\t{date}\t{status}", record.name)
})
.collect()
}
#[must_use]
pub fn initiative_record_lines(id: &InitiativeId, record: &InitiativeConfiguration) -> Vec<String> {
vec![
format!("id: {id}"),
format!("name: {}", record.name),
format!(
"target date: {}",
record.target_date.as_deref().unwrap_or("-")
),
format!("status: {}", record.status.as_deref().unwrap_or("-")),
format!("owner: {}", record.owner.as_deref().unwrap_or("-")),
format!(
"priority: {}",
record
.priority
.as_ref()
.map_or_else(|| "-".to_string(), ToString::to_string)
),
format!("outcome: {}", record.outcome.as_deref().unwrap_or("-")),
format!("labels: {}", join_or_dash(&record.labels)),
format!("projects: {}", join_or_dash(&record.projects)),
format!("epics: {}", join_or_dash(&record.epics)),
format!("milestones: {}", join_or_dash(&record.milestones)),
]
}
#[must_use]
pub fn roadmap_list_lines(projection: &RoadmapProjection) -> Vec<String> {
if projection.initiatives.is_empty() {
return vec!["(no initiatives match the roadmap filter)".to_string()];
}
let mut lines = Vec::new();
for (index, row) in projection.initiatives.iter().enumerate() {
if index > 0 {
lines.push(String::new());
}
lines.extend(roadmap_row_lines(row));
}
lines
}
#[must_use]
pub fn roadmap_row_lines(row: &RoadmapRow) -> Vec<String> {
let date = row.target_date.as_deref().unwrap_or("undated");
let mut lines = vec![format!(
"{}\t{}\t{}\t{}",
row.id,
row.name,
date,
row.summary.state.as_str()
)];
if row.summary.contributions.is_empty() {
lines.push(format!(
" ({}: no source references)",
row.summary.state.as_str()
));
} else {
lines.extend(row.summary.contributions.iter().map(contribution_line));
}
lines.extend(row.diagnostics.iter().map(diagnostic_line));
lines
}
#[must_use]
pub fn roadmap_filter_line(filter: &RoadmapFilter) -> Option<String> {
if filter.is_empty() {
return None;
}
let mut parts: Vec<String> = Vec::new();
if let Some(value) = &filter.period_from {
parts.push(format!("from={value}"));
}
if let Some(value) = &filter.period_to {
parts.push(format!("to={value}"));
}
if let Some(value) = &filter.owner {
parts.push(format!("owner={value}"));
}
if let Some(value) = &filter.status {
parts.push(format!("status={value}"));
}
if !filter.labels.is_empty() {
parts.push(format!("labels={}", filter.labels.join(",")));
}
if let Some(value) = filter.query.as_deref().filter(|q| !q.trim().is_empty()) {
parts.push(format!("query={value}"));
}
Some(format!("filter: {}", parts.join(" ")))
}
fn contribution_line(contribution: &SummaryContribution) -> String {
let value = match &contribution.progress {
Some(ProgressValue::Project(report)) => {
format!("{}/{}", report.progress.done, report.progress.total)
}
Some(ProgressValue::Epic(rollup)) => {
format!("{}/{}", rollup.progress.done, rollup.progress.total)
}
None => "-".to_string(),
};
let risk = contribution
.risk
.as_ref()
.map_or("-", |rollup| rollup.state.as_str());
let policy = contribution.policy_version.as_deref().unwrap_or("-");
format!(
" {} ({}) [{}] progress={value} risk={risk} policy={policy}",
contribution.source_entity(),
contribution.label,
contribution.resolution.as_str(),
)
}
fn diagnostic_line(diagnostic: &RoadmapDiagnostic) -> String {
let mut location = String::new();
if let Some(field) = &diagnostic.field {
location.push_str(&format!(" {field}:"));
}
if let Some(reference) = &diagnostic.reference {
location.push_str(&format!(" `{reference}`"));
}
format!(
" ! {}:{location} {}",
diagnostic.kind.as_str(),
diagnostic.message
)
}
#[must_use]
pub fn initiative_edit_requires_a_field() -> TixError {
TixError::InvalidInput("`tix initiative edit` requires at least one field flag".to_string())
}
fn association_target(
verb: &str,
project: Option<&str>,
epic: Option<&str>,
milestone: Option<&str>,
) -> Result<(AssociationKind, String)> {
match (project, epic, milestone) {
(Some(value), None, None) => Ok((AssociationKind::Project, value.to_string())),
(None, Some(value), None) => Ok((AssociationKind::Epic, value.to_string())),
(None, None, Some(value)) => Ok((AssociationKind::Milestone, value.to_string())),
(None, None, None) => Err(TixError::InvalidInput(format!(
"`tix initiative {verb}` requires exactly one of `--project`, `--epic`, or `--milestone`"
))),
_ => Err(TixError::InvalidInput(format!(
"`tix initiative {verb}` accepts only one of `--project`, `--epic`, or `--milestone`"
))),
}
}
fn unknown_initiative(reference: &str) -> TixError {
TixError::InvalidInput(format!("unknown initiative `{reference}`"))
}
#[must_use]
pub fn reference_inventory(record: &InitiativeConfiguration) -> String {
format!(
"projects: {}; epics: {}; milestones: {}",
join_or_dash(&record.projects),
join_or_dash(&record.epics),
join_or_dash(&record.milestones),
)
}
fn join_or_dash(values: &[String]) -> String {
if values.is_empty() {
"-".to_string()
} else {
values.join(", ")
}
}
fn trim_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn text_patch(value: Option<&str>) -> Patch<String> {
match value {
None => Patch::Unchanged,
Some(value) if value.trim().is_empty() => Patch::Clear,
Some(value) => Patch::Set(value.trim().to_string()),
}
}
fn reference_patch(value: Option<&str>) -> Patch<Vec<String>> {
match value {
None => Patch::Unchanged,
Some(value) if value.trim().is_empty() => Patch::Clear,
Some(value) => Patch::Set(parse_csv_list(value)),
}
}
fn loose_priority(value: &str) -> Priority {
match value.parse() {
Ok(priority) => priority,
Err(never) => match never {},
}
}