|
1 | 1 | use crate::config::Config; |
2 | 2 | use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult}; |
| 3 | +use crate::loop_planner::replan_loop_state; |
3 | 4 | use crate::loop_state::{ |
4 | 5 | LoopItemState, LoopLifecycleState, LoopState, load_loop_state, loop_state_path, |
5 | 6 | loop_state_root, validate_loop_id, |
6 | 7 | }; |
| 8 | +use crate::model::WorkItemEntry; |
7 | 9 | use std::collections::BTreeSet; |
8 | 10 |
|
9 | 11 | pub(super) fn find_reusable_loop( |
@@ -188,6 +190,86 @@ pub(super) fn ensure_loop_not_terminal(state: &LoopState, action: &str) -> Diagn |
188 | 190 | Ok(()) |
189 | 191 | } |
190 | 192 |
|
| 193 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 194 | +pub(super) enum LoopPlanStatus { |
| 195 | + Fresh, |
| 196 | + Stale, |
| 197 | +} |
| 198 | + |
| 199 | +impl LoopPlanStatus { |
| 200 | + pub(super) fn as_str(self) -> &'static str { |
| 201 | + match self { |
| 202 | + Self::Fresh => "fresh", |
| 203 | + Self::Stale => "stale", |
| 204 | + } |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +pub(super) fn loop_plan_status(config: &Config, state: &LoopState) -> LoopPlanStatus { |
| 209 | + if current_plan_matches_state(config, state).unwrap_or(false) { |
| 210 | + LoopPlanStatus::Fresh |
| 211 | + } else { |
| 212 | + LoopPlanStatus::Stale |
| 213 | + } |
| 214 | +} |
| 215 | + |
| 216 | +pub(super) fn loop_plan_status_from_work_items( |
| 217 | + state: &LoopState, |
| 218 | + all_work_items: &[WorkItemEntry], |
| 219 | +) -> LoopPlanStatus { |
| 220 | + if current_plan_matches_work_items(state, all_work_items).unwrap_or(false) { |
| 221 | + LoopPlanStatus::Fresh |
| 222 | + } else { |
| 223 | + LoopPlanStatus::Stale |
| 224 | + } |
| 225 | +} |
| 226 | + |
| 227 | +pub(super) fn ensure_loop_plan_fresh(config: &Config, state: &LoopState) -> DiagnosticResult<()> { |
| 228 | + match current_plan_matches_state(config, state) { |
| 229 | + Ok(true) => Ok(()), |
| 230 | + Ok(false) => Err(stale_loop_plan_diagnostic( |
| 231 | + state, |
| 232 | + "stored dependency closure no longer matches current Work Item files", |
| 233 | + )), |
| 234 | + Err(err) => Err(stale_loop_plan_diagnostic( |
| 235 | + state, |
| 236 | + format!( |
| 237 | + "current Work Item dependency closure cannot be resolved ({})", |
| 238 | + err.message |
| 239 | + ), |
| 240 | + )), |
| 241 | + } |
| 242 | +} |
| 243 | + |
| 244 | +// Implements [[RFC-0006:C-DEPENDENCY-SEMANTICS]] and |
| 245 | +// [[RFC-0006:C-LOOP-SCOPE-MUTATION]] by treating stored scope as a cached plan. |
| 246 | +fn current_plan_matches_state(config: &Config, state: &LoopState) -> DiagnosticResult<bool> { |
| 247 | + let all_work_items = crate::parse::load_work_items(config)?; |
| 248 | + current_plan_matches_work_items(state, &all_work_items) |
| 249 | +} |
| 250 | + |
| 251 | +fn current_plan_matches_work_items( |
| 252 | + state: &LoopState, |
| 253 | + all_work_items: &[WorkItemEntry], |
| 254 | +) -> DiagnosticResult<bool> { |
| 255 | + let plan = replan_loop_state(state, &state.loop_meta.work, all_work_items)?; |
| 256 | + Ok(plan.state.loop_meta.resolved == state.loop_meta.resolved |
| 257 | + && plan.state.dependencies == state.dependencies) |
| 258 | +} |
| 259 | + |
| 260 | +fn stale_loop_plan_diagnostic(state: &LoopState, reason: impl AsRef<str>) -> Diagnostic { |
| 261 | + Diagnostic::new( |
| 262 | + DiagnosticCode::E1201LoopStateInvalid, |
| 263 | + format!( |
| 264 | + "Loop '{}' is stale: {}. Run `govctl loop replan {}` before opening another round.", |
| 265 | + state.loop_meta.id, |
| 266 | + reason.as_ref(), |
| 267 | + state.loop_meta.id |
| 268 | + ), |
| 269 | + state.loop_meta.id.clone(), |
| 270 | + ) |
| 271 | +} |
| 272 | + |
191 | 273 | pub(super) fn generated_loop_id(config: &Config) -> DiagnosticResult<String> { |
192 | 274 | let date = chrono::Local::now().format("%Y-%m-%d").to_string(); |
193 | 275 | generated_loop_id_for_date(config, &date) |
|
0 commit comments