use std::collections::BTreeSet;
use std::path::PathBuf;
use crate::cli::{ViewAction, ViewPlanArgs, ViewScopeArgs};
use crate::config::{BoardConfiguration, Config};
use crate::error::{Result, TixError};
use crate::search::{Corpus, StructuredFilter};
use crate::storage::Store;
use crate::views::divergence::{ViewDivergence, ViewSurface};
use crate::views::plan::{ViewPlan, ViewVisibility};
use crate::views::registry::{
ViewDiagnostic, ViewDiagnosticKind, ViewRefusal, ViewScope, ViewScopeKind, ViewWriteError,
};
use crate::views::service::{DeleteConfirmed, ViewDefinition, ViewService};
type Service<'a> = ViewService<'a, BTreeSet<String>>;
pub fn run(config: &mut Config, store: &Store, action: &ViewAction) -> Result<()> {
let projects = project_directory();
let path = config_path(store);
let board = config.default_config().clone();
let resolution = crate::workflow::active_resolution(config).1;
let mut service = ViewService::new(config, &projects, &path);
match action {
ViewAction::List { scope } => list(&service, &projects, scope.as_deref()),
ViewAction::Show { reference } => show(&service, &projects, reference),
ViewAction::Create { name, scope, plan } => create(store, &mut service, name, scope, plan),
ViewAction::Update {
reference,
name,
plan,
} => update(store, &mut service, reference, name.as_deref(), plan),
ViewAction::Duplicate { reference, name } => {
duplicate(&mut service, reference, name.as_deref())
}
ViewAction::Delete { reference, confirm } => delete(&mut service, reference, *confirm),
ViewAction::Apply { reference } => {
apply(&mut service, store, reference, &board, &resolution)
}
ViewAction::Status { reference, live } => {
status(&service, store, reference.as_deref(), live)
}
ViewAction::Share { reference, scope } => share(&mut service, reference, scope),
}
}
pub(crate) fn project_directory() -> BTreeSet<String> {
BTreeSet::new()
}
pub(crate) fn config_path(store: &Store) -> PathBuf {
Config::project_root(&store.issues_dir).map_or_else(
|| PathBuf::from(".").join("tix.toml"),
|root| root.join("tix.toml"),
)
}
fn list(service: &Service<'_>, projects: &BTreeSet<String>, scope: Option<&str>) -> Result<()> {
let wanted = scope.map(parse_scope_kind).transpose()?;
for view in service.list() {
if wanted.is_some_and(|kind| view.scope.kind != kind) {
continue;
}
println!(
"{}\t{}\t{}\t{}",
view.id,
view.name,
scope_label(&view.scope),
view.scope.resolution(projects)
);
}
Ok(())
}
fn show(service: &Service<'_>, projects: &BTreeSet<String>, reference: &str) -> Result<()> {
let detail = service.show(reference).map_err(refusal_error)?;
let view = detail.view();
println!("id: {}", view.id);
println!("name: {}", view.name);
println!("scope: {}", scope_label(&view.scope));
println!("resolution: {}", view.scope.resolution(projects));
for line in plan_lines(detail.plan()) {
println!("{line}");
}
for diagnostic in detail.diagnostics() {
println!("diagnostic: {}", render_diagnostic(diagnostic));
}
Ok(())
}
fn status(
service: &Service<'_>,
store: &Store,
reference: Option<&str>,
live_args: &ViewPlanArgs,
) -> Result<()> {
let live = plan_from_args(store, live_args)?;
let divergence = match reference {
Some(reference) => {
let detail = service.show(reference).map_err(refusal_error)?;
println!("id: {}", detail.view().id);
println!("name: {}", detail.view().name);
ViewDivergence::between(detail.plan(), &live)
}
None => ViewDivergence::missing_marker(&live),
};
println!("in-sync: {}", divergence.in_sync());
for difference in divergence.differences() {
println!(
"difference: {} saved={} live={}",
difference.field(),
difference.saved().unwrap_or("(unset)"),
difference.live().unwrap_or("(unset)")
);
}
for diagnostic in divergence.diagnostics() {
println!("diagnostic: {}", render_diagnostic(diagnostic));
}
Ok(())
}
fn create(
store: &Store,
service: &mut Service<'_>,
name: &str,
scope_args: &ViewScopeArgs,
plan_args: &ViewPlanArgs,
) -> Result<()> {
let scope = build_scope(scope_args).map_err(refusal_error)?;
let plan = plan_from_args(store, plan_args)?;
let outcome = service
.create(ViewDefinition::new(name, scope, plan))
.map_err(write_error)?;
println!("Created {}", outcome.into_value());
Ok(())
}
fn update(
store: &Store,
service: &mut Service<'_>,
reference: &str,
name: Option<&str>,
plan_args: &ViewPlanArgs,
) -> Result<()> {
let detail = service.show(reference).map_err(refusal_error)?;
let new_name = name
.map(str::to_string)
.unwrap_or_else(|| detail.view().name.clone());
let scope = detail.view().scope.clone();
let plan = if plan_args.is_any() {
overlay_plan_args(store, detail.plan(), plan_args)?
} else {
detail.plan().clone()
};
let outcome = service
.update(reference, ViewDefinition::new(new_name, scope, plan))
.map_err(write_error)?;
let word = if outcome.is_noop() {
"Unchanged"
} else {
"Updated"
};
println!("{word} {}", outcome.value());
Ok(())
}
fn duplicate(service: &mut Service<'_>, reference: &str, name: Option<&str>) -> Result<()> {
let outcome = service.duplicate(reference, name).map_err(write_error)?;
println!("Duplicated {}", outcome.into_value());
Ok(())
}
fn delete(service: &mut Service<'_>, reference: &str, confirm: bool) -> Result<()> {
if !confirm {
return Err(TixError::InvalidInput(
"deleting a view needs --confirm: it removes the saved definition, never its \
issues or comments"
.to_string(),
));
}
let outcome = service
.delete(reference, DeleteConfirmed::confirmed())
.map_err(write_error)?;
let removed = outcome.value();
println!("Deleted {}", removed.view().id);
if removed.marker_cleared() {
println!("active-marker: cleared");
}
Ok(())
}
fn share(service: &mut Service<'_>, reference: &str, scope_args: &ViewScopeArgs) -> Result<()> {
let detail = service.show(reference).map_err(refusal_error)?;
let name = detail.view().name.clone();
let plan = detail.plan().clone();
let scope = build_scope(scope_args).map_err(refusal_error)?;
let outcome = service
.update(reference, ViewDefinition::new(name, scope, plan))
.map_err(write_error)?;
let word = if outcome.is_noop() {
"Unchanged"
} else {
"Shared"
};
println!("{word} {}", outcome.value());
Ok(())
}
fn apply(
service: &mut Service<'_>,
store: &Store,
reference: &str,
board: &BoardConfiguration,
resolution: &crate::workflow::CategoryResolution,
) -> Result<()> {
let detail = service.show(reference).map_err(refusal_error)?;
service
.mark_active(reference, Some(ViewSurface::Cli))
.map_err(refusal_error)?;
let corpus = Corpus::build(store)?;
let result = service
.apply(reference, board, &corpus, resolution)
.map_err(refusal_error)?;
println!("id: {}", detail.view().id);
println!("name: {}", detail.view().name);
println!("scope: {}", scope_label(&detail.view().scope));
for line in plan_lines(detail.plan()) {
println!("{line}");
}
println!("active: {}", detail.view().id);
println!("issues: {}", join_numbers(result.issue_numbers()));
println!("count: {}", result.issue_numbers().len());
for diagnostic in result.diagnostics() {
println!("diagnostic: {}", render_diagnostic(diagnostic));
}
Ok(())
}
pub(crate) fn overlay_plan_args(
store: &Store,
base: &ViewPlan,
args: &ViewPlanArgs,
) -> Result<ViewPlan> {
let mut plan = match args.filter.as_deref() {
Some(filter) => {
let (query, criteria) = crate::search::resolve_filter_criteria(store, filter)?;
let mut rebuilt = ViewPlan::from_parts(criteria, &query);
if args.sort.is_none() && rebuilt.sort().is_none() {
if let Some(sort) = base.sort() {
rebuilt = rebuilt.with_sort(sort);
}
}
if args.group.is_none() && rebuilt.group().is_none() {
if let Some(group) = base.group() {
rebuilt = rebuilt.with_group(group);
}
}
if args.hide_columns.is_none() && args.show_mismatch.is_none() {
if let Some(visibility) = base.visibility() {
rebuilt = rebuilt.with_visibility(visibility.clone());
}
}
rebuilt.with_extensions(base.extensions().clone())
}
None => base.clone(),
};
if let Some(sort) = args.sort.as_deref() {
plan = plan.with_sort(sort);
}
if let Some(group) = args.group.as_deref() {
plan = plan.with_group(group);
}
if args.hide_columns.is_some() || args.show_mismatch.is_some() {
let hidden = split_list(args.hide_columns.as_deref());
plan = plan.with_visibility(ViewVisibility::new(hidden, args.show_mismatch));
}
Ok(plan)
}
pub(crate) fn plan_from_args(store: &Store, args: &ViewPlanArgs) -> Result<ViewPlan> {
let (query, criteria) = match args.filter.as_deref() {
Some(filter) => crate::search::resolve_filter_criteria(store, filter)?,
None => (String::new(), StructuredFilter::default()),
};
let mut plan = ViewPlan::from_parts(criteria, &query);
if let Some(sort) = args.sort.as_deref() {
plan = plan.with_sort(sort);
}
if let Some(group) = args.group.as_deref() {
plan = plan.with_group(group);
}
if args.hide_columns.is_some() || args.show_mismatch.is_some() {
let hidden = split_list(args.hide_columns.as_deref());
plan = plan.with_visibility(ViewVisibility::new(hidden, args.show_mismatch));
}
Ok(plan)
}
pub(crate) fn build_scope(args: &ViewScopeArgs) -> std::result::Result<ViewScope, ViewRefusal> {
let kind = match args.scope.as_deref() {
None | Some("repository") => ViewScopeKind::Repository,
Some("project") => ViewScopeKind::Project,
Some("user" | "user_share") => ViewScopeKind::UserShare,
Some(other) => {
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
format!("unknown view scope `{other}`; expected repository, project, or user"),
)
.with_field("scope")
.with_remedy("pass `--scope repository`, `--scope project`, or `--scope user`")
.into());
}
};
Ok(ViewScope {
kind,
project_id: args.project.clone(),
owner: args.owner.clone(),
shared_with: split_list(args.shared_with.as_deref()),
extensions: toml::Table::new(),
})
}
pub(crate) fn parse_scope_kind(raw: &str) -> Result<ViewScopeKind> {
match raw {
"repository" | "repo" => Ok(ViewScopeKind::Repository),
"project" => Ok(ViewScopeKind::Project),
"user" | "user_share" | "share" => Ok(ViewScopeKind::UserShare),
other => Err(TixError::InvalidInput(format!(
"unknown view scope `{other}`; expected repository, project, or user"
))),
}
}
fn split_list(raw: Option<&str>) -> Vec<String> {
raw.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
pub(crate) fn plan_lines(plan: &ViewPlan) -> Vec<String> {
let mut lines = vec![format!("filter: {}", plan.filter())];
if let Some(query) = plan.text_query() {
lines.push(format!("query: {query}"));
}
if let Some(sort) = plan.sort() {
lines.push(format!("sort: {sort}"));
}
if let Some(group) = plan.group() {
lines.push(format!("group: {group}"));
}
if let Some(visibility) = plan.visibility() {
lines.push(format!(
"hide-columns: {}",
visibility.hidden_columns().join(",")
));
lines.push(format!(
"show-mismatch: {}",
visibility
.show_mismatch()
.map_or_else(|| "inherit".to_string(), |value| value.to_string())
));
}
lines
}
pub(crate) fn scope_label(scope: &ViewScope) -> String {
match scope.kind {
ViewScopeKind::Repository => "repository".to_string(),
ViewScopeKind::Project => {
format!("project {}", scope.project_id.as_deref().unwrap_or(""))
}
ViewScopeKind::UserShare => {
let mut parts = vec!["user".to_string()];
if let Some(owner) = scope.owner.as_deref() {
parts.push(format!("owner={owner}"));
}
if !scope.shared_with.is_empty() {
parts.push(format!("shared_with={}", scope.shared_with.join(",")));
}
parts.join(" ")
}
}
}
pub(crate) fn render_diagnostic(diagnostic: &ViewDiagnostic) -> String {
match diagnostic.field() {
Some(field) => format!("{} [{field}]: {}", diagnostic.kind(), diagnostic.message()),
None => format!("{}: {}", diagnostic.kind(), diagnostic.message()),
}
}
pub(crate) fn join_numbers(numbers: &[u32]) -> String {
if numbers.is_empty() {
"(none)".to_string()
} else {
numbers
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",")
}
}
fn refusal_error(refusal: ViewRefusal) -> TixError {
let rendered = refusal.render();
if refusal.kind() == ViewDiagnosticKind::UnknownView {
TixError::NotFound(rendered)
} else {
TixError::InvalidInput(rendered)
}
}
fn write_error(error: ViewWriteError) -> TixError {
match error {
ViewWriteError::Refused(refusal) => refusal_error(refusal),
ViewWriteError::Failed(error) => error,
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::views::registry::ScopeResolution;
#[test]
fn scope_labels_name_the_family_and_its_metadata() {
assert_eq!(scope_label(&ViewScope::repository()), "repository");
assert_eq!(
scope_label(&ViewScope::project("p-platform")),
"project p-platform"
);
assert_eq!(
scope_label(&ViewScope::user_share(
Some("alice".to_string()),
vec!["bob".to_string()]
)),
"user owner=alice shared_with=bob"
);
}
#[test]
fn a_repository_scope_carrying_a_project_is_left_for_the_registry_to_refuse() {
let args = ViewScopeArgs {
scope: None,
project: Some("p-platform".to_string()),
owner: None,
shared_with: None,
};
let scope = build_scope(&args).unwrap();
assert_eq!(scope.kind, ViewScopeKind::Repository);
assert!(
!scope.conflicts().is_empty(),
"the adapter must not silently drop the project id"
);
}
#[test]
fn an_unknown_scope_word_is_refused_with_the_expected_vocabulary() {
let args = ViewScopeArgs {
scope: Some("team".to_string()),
project: None,
owner: None,
shared_with: None,
};
let refusal = build_scope(&args).unwrap_err();
assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
assert!(refusal.message().contains("repository, project, or user"));
}
#[test]
fn split_list_trims_and_drops_blanks() {
assert_eq!(split_list(Some(" a, b ,,c ")), vec!["a", "b", "c"]);
assert_eq!(split_list(None), [] as [std::string::String; 0]);
}
#[test]
fn join_numbers_is_comma_separated_and_says_none() {
assert_eq!(join_numbers(&[1, 2, 3]), "1,2,3");
assert_eq!(join_numbers(&[]), "(none)");
}
#[test]
fn an_unresolvable_project_scope_reports_missing_rather_than_resolved() {
let scope = ViewScope::project("p-platform");
let empty: BTreeSet<String> = BTreeSet::new();
assert_eq!(scope.resolution(&empty), ScopeResolution::Missing);
let mut known = BTreeSet::new();
known.insert("p-platform".to_string());
assert_eq!(scope.resolution(&known), ScopeResolution::Resolved);
}
}