Skip to main content

driven/cli/
strategy.rs

1//! CLI helpers for DX lane/pass strategy commands.
2
3use crate::Result;
4use crate::strategy::artifacts::read_receipt_artifact;
5use crate::strategy::{
6    LaneClaim, LaneId, LanePassAssignment, LanePassConfig, LanePassStore, OutcomeProof, PassNumber,
7    ProofReceipt, ReceiptFormat, WorkerId, WorktreeIsolationPlan, WorktreeMetadata,
8    detect_worktree_metadata,
9};
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub struct StrategyCommand;
15
16#[derive(Debug, Clone, Default)]
17pub struct StrategyStateOptions {
18    pub cycle_lanes: bool,
19    pub project_root: Option<PathBuf>,
20    pub strict_isolation: bool,
21    pub handoff_required_for_next: bool,
22}
23
24impl StrategyStateOptions {
25    pub fn with_lane_cycling(mut self, cycle_lanes: bool) -> Self {
26        self.cycle_lanes = cycle_lanes;
27        self
28    }
29
30    pub fn with_project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
31        self.project_root = Some(project_root.into());
32        self
33    }
34
35    pub fn with_strict_isolation(mut self, strict_isolation: bool) -> Self {
36        self.strict_isolation = strict_isolation;
37        self
38    }
39
40    pub fn with_handoff_required_for_next(mut self, required: bool) -> Self {
41        self.handoff_required_for_next = required;
42        self
43    }
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct StrategyClaimOutput {
48    pub claim: LaneClaim,
49    pub receipt_id: String,
50    pub handoff: crate::strategy::NextPassHandoff,
51    pub worktree: WorktreeMetadata,
52    pub worktree_plan: WorktreeIsolationPlan,
53    pub recommended_small_commands: Vec<String>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct StrategyWorktreeInspection {
58    pub worktree: WorktreeMetadata,
59    pub worktree_plan: WorktreeIsolationPlan,
60}
61
62impl StrategyCommand {
63    pub fn inspect_worktree(path: &Path, json: bool) -> Result<String> {
64        let metadata = detect_worktree_metadata(path);
65        let plan = WorktreeIsolationPlan::from_metadata(metadata.clone());
66        let inspection = StrategyWorktreeInspection {
67            worktree: metadata,
68            worktree_plan: plan,
69        };
70        if json {
71            return serde_json::to_string_pretty(&inspection)
72                .map(|mut output| {
73                    output.push('\n');
74                    output
75                })
76                .map_err(|e| crate::DrivenError::Format(format!("failed to render JSON: {}", e)));
77        }
78
79        Ok(format!(
80            "Worktree kind: {:?}\nRoot: {}\nBranch: {}\nDirty: {}\nDecision: {:?}\n",
81            inspection.worktree.kind,
82            inspection
83                .worktree
84                .worktree_root
85                .as_ref()
86                .map(|path| path.display().to_string())
87                .unwrap_or_else(|| "not a repository".to_string()),
88            inspection
89                .worktree
90                .branch
91                .as_deref()
92                .unwrap_or("detached-or-none"),
93            inspection
94                .worktree
95                .is_dirty
96                .map(|dirty| dirty.to_string())
97                .unwrap_or_else(|| "unknown".to_string()),
98            inspection.worktree_plan.creation_decision
99        ))
100    }
101
102    pub fn claim(
103        project_root: &Path,
104        lane: u8,
105        pass: u32,
106        worker_id: &str,
107        scope: &str,
108        next_action: &str,
109        json: bool,
110    ) -> Result<String> {
111        let worker = WorkerId::new(worker_id)?;
112        let claim = LaneClaim::new(LaneId::new(lane)?, PassNumber::new(pass)?, worker, scope);
113        let receipt = ProofReceipt::new(claim.clone(), "lane/pass claim modeled").with_outcome(
114            OutcomeProof::partial(
115                "claim created; run verification commands before closing the pass",
116            ),
117        );
118        let receipt_id = receipt.receipt_id()?;
119        let handoff = claim.next_pass_handoff(receipt.clone(), next_action)?;
120
121        let worktree = detect_worktree_metadata(project_root);
122        let worktree_plan = WorktreeIsolationPlan::from_metadata(worktree.clone());
123        let output = StrategyClaimOutput {
124            claim,
125            receipt_id,
126            handoff,
127            worktree,
128            worktree_plan,
129            recommended_small_commands: vec![
130                "git status --short --branch".to_string(),
131                "rg -n \"(<{7}|>{7}|={7})\"".to_string(),
132                "cargo fmt --check".to_string(),
133            ],
134        };
135
136        if json {
137            serde_json::to_string_pretty(&output)
138                .map(|mut output| {
139                    output.push('\n');
140                    output
141                })
142                .map_err(|e| crate::DrivenError::Format(format!("failed to render JSON: {}", e)))
143        } else {
144            render_claim_markdown(&output, &receipt)
145        }
146    }
147
148    pub fn peek_state(
149        state_dir: PathBuf,
150        scope: &str,
151        max_lanes: u8,
152        max_passes: u32,
153        json: bool,
154    ) -> Result<String> {
155        Self::peek_state_with_options(
156            state_dir,
157            scope,
158            max_lanes,
159            max_passes,
160            json,
161            StrategyStateOptions::default(),
162        )
163    }
164
165    pub fn peek_state_with_options(
166        state_dir: PathBuf,
167        scope: &str,
168        max_lanes: u8,
169        max_passes: u32,
170        json: bool,
171        options: StrategyStateOptions,
172    ) -> Result<String> {
173        let store = LanePassStore::new(
174            state_config(state_dir, scope, &options)
175                .with_max_lanes(max_lanes)
176                .with_max_passes(max_passes),
177        )?;
178        render_assignment(&store.peek_next_claim()?, json)
179    }
180
181    pub fn claim_state(
182        state_dir: PathBuf,
183        scope: &str,
184        max_lanes: u8,
185        max_passes: u32,
186        worker_id: &str,
187        json: bool,
188    ) -> Result<String> {
189        Self::claim_state_with_options(
190            state_dir,
191            scope,
192            max_lanes,
193            max_passes,
194            worker_id,
195            json,
196            StrategyStateOptions::default(),
197        )
198    }
199
200    pub fn claim_state_with_options(
201        state_dir: PathBuf,
202        scope: &str,
203        max_lanes: u8,
204        max_passes: u32,
205        worker_id: &str,
206        json: bool,
207        options: StrategyStateOptions,
208    ) -> Result<String> {
209        let config = state_config(state_dir, scope, &options)
210            .with_max_lanes(max_lanes)
211            .with_max_passes(max_passes);
212        enforce_strict_isolation(&config, &options)?;
213        let store = LanePassStore::new(config)?;
214        render_assignment(&store.claim(WorkerId::new(worker_id)?)?, json)
215    }
216
217    pub fn next_state(
218        state_dir: PathBuf,
219        scope: &str,
220        max_lanes: u8,
221        max_passes: u32,
222        worker_id: &str,
223        json: bool,
224    ) -> Result<String> {
225        Self::next_state_with_options(
226            state_dir,
227            scope,
228            max_lanes,
229            max_passes,
230            worker_id,
231            json,
232            StrategyStateOptions::default(),
233        )
234    }
235
236    pub fn next_state_with_options(
237        state_dir: PathBuf,
238        scope: &str,
239        max_lanes: u8,
240        max_passes: u32,
241        worker_id: &str,
242        json: bool,
243        options: StrategyStateOptions,
244    ) -> Result<String> {
245        let config = state_config(state_dir, scope, &options)
246            .with_max_lanes(max_lanes)
247            .with_max_passes(max_passes);
248        enforce_strict_isolation(&config, &options)?;
249        let store = LanePassStore::new(config)?;
250        render_assignment(&store.next_pass(&WorkerId::new(worker_id)?)?, json)
251    }
252
253    pub fn next_state_with_handoff_options(
254        state_dir: PathBuf,
255        scope: &str,
256        max_lanes: u8,
257        max_passes: u32,
258        worker_id: &str,
259        receipt_path: &Path,
260        next_action: &str,
261        json: bool,
262        options: StrategyStateOptions,
263    ) -> Result<String> {
264        let config = state_config(state_dir, scope, &options)
265            .with_max_lanes(max_lanes)
266            .with_max_passes(max_passes);
267        enforce_strict_isolation(&config, &options)?;
268        let store = LanePassStore::new(config)?;
269        let worker = WorkerId::new(worker_id)?;
270        let receipt = read_receipt_artifact(receipt_path)?;
271        render_assignment(
272            &store.next_pass_with_handoff(&worker, &receipt, next_action)?,
273            json,
274        )
275    }
276
277    pub fn release_state(
278        state_dir: PathBuf,
279        scope: &str,
280        max_lanes: u8,
281        max_passes: u32,
282        worker_id: &str,
283        json: bool,
284    ) -> Result<String> {
285        Self::release_state_with_options(
286            state_dir,
287            scope,
288            max_lanes,
289            max_passes,
290            worker_id,
291            json,
292            StrategyStateOptions::default(),
293        )
294    }
295
296    pub fn release_state_with_options(
297        state_dir: PathBuf,
298        scope: &str,
299        max_lanes: u8,
300        max_passes: u32,
301        worker_id: &str,
302        json: bool,
303        options: StrategyStateOptions,
304    ) -> Result<String> {
305        let config = state_config(state_dir, scope, &options)
306            .with_max_lanes(max_lanes)
307            .with_max_passes(max_passes);
308        enforce_strict_isolation(&config, &options)?;
309        let store = LanePassStore::new(config)?;
310        render_assignment(&store.release_lane(&WorkerId::new(worker_id)?)?, json)
311    }
312
313    pub fn complete_state(
314        _state_dir: PathBuf,
315        _scope: &str,
316        _max_lanes: u8,
317        _max_passes: u32,
318        _worker_id: &str,
319        _json: bool,
320    ) -> Result<String> {
321        Err(crate::DrivenError::Validation(
322            "completion requires a canonical proof receipt; use complete_state_with_receipt"
323                .to_string(),
324        ))
325    }
326
327    pub fn complete_state_with_receipt(
328        state_dir: PathBuf,
329        scope: &str,
330        max_lanes: u8,
331        max_passes: u32,
332        worker_id: &str,
333        receipt_path: &Path,
334        json: bool,
335    ) -> Result<String> {
336        Self::complete_state_with_receipt_options(
337            state_dir,
338            scope,
339            max_lanes,
340            max_passes,
341            worker_id,
342            receipt_path,
343            json,
344            StrategyStateOptions::default(),
345        )
346    }
347
348    pub fn complete_state_with_receipt_options(
349        state_dir: PathBuf,
350        scope: &str,
351        max_lanes: u8,
352        max_passes: u32,
353        worker_id: &str,
354        receipt_path: &Path,
355        json: bool,
356        options: StrategyStateOptions,
357    ) -> Result<String> {
358        let config = state_config(state_dir, scope, &options)
359            .with_max_lanes(max_lanes)
360            .with_max_passes(max_passes);
361        enforce_strict_isolation(&config, &options)?;
362        let store = LanePassStore::new(config)?;
363        let worker = WorkerId::new(worker_id)?;
364        let receipt = read_receipt_artifact(receipt_path)?;
365        render_assignment(&store.complete_pass_with_receipt(&worker, &receipt)?, json)
366    }
367}
368
369pub(super) fn render_receipt(receipt: &ProofReceipt, json: bool) -> Result<String> {
370    if json {
371        return receipt.render(ReceiptFormat::Json);
372    }
373
374    receipt.render(ReceiptFormat::Markdown)
375}
376
377fn render_claim_markdown(output: &StrategyClaimOutput, receipt: &ProofReceipt) -> Result<String> {
378    let mut markdown = String::new();
379    markdown.push_str("# DX Lane/Pass Claim\n\n");
380    markdown.push_str(&format!("- Lane: {}\n", output.claim.lane));
381    markdown.push_str(&format!("- Pass: {}\n", output.claim.pass));
382    markdown.push_str(&format!("- Worker: {}\n", output.claim.worker_id));
383    markdown.push_str(&format!(
384        "- Scope: {}\n",
385        escape_markdown_text(&output.claim.scope)
386    ));
387    markdown.push_str(&format!("- Next pass: {}\n", output.handoff.next_pass));
388    markdown.push_str(&format!(
389        "- Next action: {}\n",
390        escape_markdown_text(&output.handoff.next_action)
391    ));
392    markdown.push_str(&format!("- Worktree kind: {:?}\n\n", output.worktree.kind));
393    markdown.push_str(&format!(
394        "- Worktree decision: {:?}\n",
395        output.worktree_plan.creation_decision
396    ));
397    for blocker in &output.worktree_plan.blockers {
398        markdown.push_str(&format!(
399            "- Worktree blocker: {}\n",
400            escape_markdown_text(blocker)
401        ));
402    }
403    markdown.push('\n');
404    markdown.push_str("## Small Commands First\n");
405    for command in &output.recommended_small_commands {
406        markdown.push_str(&format!("- `{}`\n", command));
407    }
408    markdown.push('\n');
409    markdown.push_str(&receipt.render(ReceiptFormat::Markdown)?);
410    Ok(markdown)
411}
412
413fn render_assignment(assignment: &LanePassAssignment, json: bool) -> Result<String> {
414    if json {
415        return serde_json::to_string_pretty(assignment)
416            .map(|mut output| {
417                output.push('\n');
418                output
419            })
420            .map_err(|e| crate::DrivenError::Format(format!("failed to render JSON: {}", e)));
421    }
422
423    Ok(format!(
424        "# DX Lane/Pass Assignment\n\n- Status: {:?}\n- Scope: {}\n- Lane: {}\n- Pass: {}\n- Worker: {}\n- Counter: {}\n- Claims: {}\n",
425        assignment.status,
426        escape_markdown_text(&assignment.scope),
427        assignment.lane,
428        assignment.pass,
429        assignment.worker_id,
430        assignment.paths.counter_path.display(),
431        assignment.paths.claims_path.display()
432    ))
433}
434
435pub(super) fn state_config(
436    state_dir: PathBuf,
437    scope: &str,
438    options: &StrategyStateOptions,
439) -> LanePassConfig {
440    let config = LanePassConfig::new(state_dir, scope);
441    let config = if let Some(project_root) = &options.project_root {
442        config.with_project_root(project_root.clone())
443    } else {
444        match std::env::current_dir() {
445            Ok(project_root) => config.with_project_root(project_root),
446            Err(_) => config,
447        }
448    };
449    config
450        .with_lane_cycling(options.cycle_lanes)
451        .with_handoff_required_for_next(options.handoff_required_for_next)
452}
453
454pub(super) fn enforce_strict_isolation(
455    config: &LanePassConfig,
456    options: &StrategyStateOptions,
457) -> Result<()> {
458    if !options.strict_isolation {
459        return Ok(());
460    }
461    let project_root = match &config.project_root {
462        Some(project_root) => project_root.clone(),
463        None => std::env::current_dir().map_err(crate::DrivenError::Io)?,
464    };
465    let metadata = detect_worktree_metadata(&project_root);
466    let plan = WorktreeIsolationPlan::from_metadata(metadata);
467    if plan.native_isolation_detected && plan.blockers.is_empty() && plan.warnings.is_empty() {
468        return Ok(());
469    }
470    Err(crate::DrivenError::Validation(format!(
471        "strict isolation requires a clean isolated worktree; decision={:?}; blockers={}; warnings={}",
472        plan.creation_decision,
473        plan.blockers.join("; "),
474        plan.warnings.join("; ")
475    )))
476}
477
478pub(super) fn unix_seconds_now() -> Result<u64> {
479    SystemTime::now()
480        .duration_since(UNIX_EPOCH)
481        .map(|duration| duration.as_secs())
482        .map_err(|e| crate::DrivenError::Validation(format!("system time is before epoch: {}", e)))
483}
484
485pub(super) fn command_display(program: &str, args: &[String]) -> String {
486    std::iter::once(program.to_string())
487        .chain(args.iter().cloned())
488        .map(|arg| display_command_arg(&arg))
489        .collect::<Vec<_>>()
490        .join(" ")
491}
492
493fn display_command_arg(arg: &str) -> String {
494    if arg
495        .chars()
496        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | '\\' | ':'))
497    {
498        return arg.to_string();
499    }
500    format!("{:?}", arg)
501}
502
503fn escape_markdown_text(value: &str) -> String {
504    value
505        .replace('\r', "")
506        .replace('|', "\\|")
507        .replace('\n', "<br>")
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn claim_json_contains_lane_worker_and_next_action() {
516        let temp = tempfile::tempdir().unwrap();
517        let output = StrategyCommand::claim(
518            temp.path(),
519            2,
520            1,
521            "worker-cli",
522            "cli strategy",
523            "continue with receipts",
524            true,
525        )
526        .unwrap();
527
528        assert!(output.contains("\"lane\": 2"));
529        assert!(output.contains("\"worker_id\": \"worker-cli\""));
530        assert!(output.contains("continue with receipts"));
531    }
532
533    #[test]
534    fn claim_state_json_preserves_lane_across_next_pass() {
535        let temp = tempfile::tempdir().unwrap();
536        let state_dir = temp.path().join("state");
537
538        let claimed =
539            StrategyCommand::claim_state(state_dir.clone(), "cli state", 30, 3, "worker-cli", true)
540                .unwrap();
541        let next =
542            StrategyCommand::next_state(state_dir, "cli state", 30, 3, "worker-cli", true).unwrap();
543
544        assert!(claimed.contains("\"lane\": 1"));
545        assert!(claimed.contains("\"pass\": 1"));
546        assert!(next.contains("\"lane\": 1"));
547        assert!(next.contains("\"pass\": 2"));
548    }
549}