use crate::cmd::verify;
use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult, Diagnostics};
use crate::model::{ChecklistStatus, WorkItemStatus};
use crate::parse::{load_releases, load_work_item, write_work_item};
use crate::ui;
use crate::validate::is_valid_work_transition;
use crate::write::{WriteOp, today};
use std::path::Path;
pub fn move_item(
config: &Config,
file: &Path,
status: WorkItemStatus,
op: WriteOp,
) -> DiagnosticResult<Diagnostics> {
let work_path = if file.is_absolute() || file.exists() {
file.to_path_buf()
} else {
let in_work_dir = config.work_dir().join(file);
if in_work_dir.exists() {
in_work_dir
} else {
find_work_item_by_name(config, &file.to_string_lossy())?
}
};
let mut entry = load_work_item(config, &work_path)?;
let work_id = &entry.spec.govctl.id;
let previous_status = entry.spec.govctl.status;
if !is_valid_work_transition(previous_status, status) {
return Err(Diagnostic::new(
DiagnosticCode::E0403WorkInvalidTransition,
format!(
"Invalid transition: {} -> {}. Valid transitions from {}: {}",
entry.spec.govctl.status.as_ref(),
status.as_ref(),
entry.spec.govctl.status.as_ref(),
valid_work_targets(previous_status)
),
work_id,
));
}
if previous_status == WorkItemStatus::Done && status == WorkItemStatus::Active {
let releases = load_releases(config)?;
if releases
.releases
.iter()
.any(|release| release.refs.iter().any(|id| id == work_id))
{
return Err(Diagnostic::new(
DiagnosticCode::E0403WorkInvalidTransition,
"Cannot reopen a Work Item referenced by a release. Released Work Items have no valid transition away from done.",
work_id,
));
}
}
if status == WorkItemStatus::Done {
if entry.spec.content.acceptance_criteria.is_empty() {
return Err(Diagnostic::new(
DiagnosticCode::E0407WorkMissingCriteria,
format!(
"Cannot mark as done: no acceptance criteria defined.\n\
Add criteria with: govctl add {} acceptance_criteria \"<criterion>\"",
work_id
),
work_id,
));
}
let pending: Vec<_> = entry
.spec
.content
.acceptance_criteria
.iter()
.filter(|c| c.status == ChecklistStatus::Pending)
.map(|c| c.text.as_str())
.collect();
if !pending.is_empty() {
let list = pending
.iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<_>>()
.join("\n");
return Err(Diagnostic::new(
DiagnosticCode::E0407WorkMissingCriteria,
format!(
"Cannot mark as done: {} pending acceptance criteria:\n{}",
pending.len(),
list
),
work_id,
));
}
verify::enforce_work_item_guards(config, &entry)?;
}
entry.spec.govctl.status = status;
match (previous_status, status) {
(WorkItemStatus::Queue, WorkItemStatus::Active) => {
if entry.spec.govctl.started.is_none() {
entry.spec.govctl.started = Some(today());
}
}
(WorkItemStatus::Done, WorkItemStatus::Active) => {
entry.spec.govctl.completed = None;
}
(_, WorkItemStatus::Done | WorkItemStatus::Cancelled) => {
entry.spec.govctl.completed = Some(today());
}
_ => {}
}
write_work_item(
&work_path,
&entry.spec,
op,
Some(&config.display_path(&work_path)),
)?;
if !op.is_preview() {
let filename = work_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| work_path.display().to_string());
ui::moved(&filename, status.as_ref());
}
Ok(vec![])
}
fn valid_work_targets(status: WorkItemStatus) -> &'static str {
match status {
WorkItemStatus::Queue => "active, cancelled",
WorkItemStatus::Active => "done, cancelled",
WorkItemStatus::Done => "active when no release references the Work Item",
WorkItemStatus::Cancelled => "none (cancelled is terminal)",
}
}
fn find_work_item_by_name(config: &Config, name: &str) -> DiagnosticResult<std::path::PathBuf> {
if name.starts_with("WI-") {
match crate::artifact_catalog::load_work_item_by_id(config, name) {
Ok(item) => return Ok(item.path),
Err(err) if err.code == DiagnosticCode::E0402WorkNotFound => {}
Err(err) => return Err(err),
}
}
let work_dir = &config.work_dir();
if !work_dir.exists() {
return Err(Diagnostic::new(
DiagnosticCode::E0405WorkDirNotFound,
format!("Work directory not found: {}", work_dir.display()),
work_dir.display().to_string(),
));
}
let entries: Vec<_> = std::fs::read_dir(work_dir)
.map_err(|err| {
Diagnostic::io_error("read work directory", err, work_dir.display().to_string())
})?
.filter_map(Result::ok)
.filter(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.contains(name))
.unwrap_or(false)
})
.collect();
match entries.len() {
0 => Err(Diagnostic::new(
DiagnosticCode::E0402WorkNotFound,
format!("No work item found matching: {name}"),
name,
)),
1 => Ok(entries[0].path()),
_ => {
let names: Vec<_> = entries
.iter()
.filter_map(|e| e.file_name().to_str().map(String::from))
.collect();
Err(Diagnostic::new(
DiagnosticCode::E0406WorkAmbiguousMatch,
format!("Multiple work items match '{}': {}", name, names.join(", ")),
name,
))
}
}
}