Skip to main content

verbs/
workflow.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Ready / land workflow domain: pure preflight, policy, and step accounting.
3//!
4//! Owns decision logic shared by `heddle ready`, `heddle land`, and `heddle sync`:
5//! - verification fail-closed preflight for readiness
6//! - land push option planning
7//! - auto-land confidence / verification policy blockers
8//! - non-staleness / heavy-impact classification
9//! - land performed / skipped step accounting
10//! - integrated-land next-action selection
11//! - ready classification and report next-action filtering
12//!
13//! Network, mutation, capture, and render remain CLI-owned.
14
15use std::path::{Path, PathBuf};
16
17use objects::object::StateId;
18use oplog::OpRecord;
19use repo::{GitImportGuidance, GitRemoteTrackingStatus, RepositoryOperationStatus, shell_quote};
20
21use crate::{
22    RepositoryVerificationState, ThreadPreviewReport,
23    status::next_action::{NextActionInput, effective_next_action, non_empty_action},
24};
25
26/// Minimum agent confidence allowed for automatic land without re-capture.
27pub const AUTO_LAND_CONFIDENCE_THRESHOLD: f32 = 0.75;
28
29/// Recovery breadcrumb when auto-land policy blocks on confidence / tests.
30pub const AUTO_LAND_CONFIDENCE_RECOVERY_ACTION: &str =
31    "heddle capture -m \"...\" --confidence <confidence>";
32
33// ---------------------------------------------------------------------------
34// Ready verification preflight
35// ---------------------------------------------------------------------------
36
37/// Statuses that must fail closed before readiness / land preflight can run.
38pub fn ready_verification_preflight_blocks(trust: &RepositoryVerificationState) -> bool {
39    ready_verification_status_blocks(trust.status.as_str())
40}
41
42/// Pure status-string check used by [`ready_verification_preflight_blocks`].
43pub fn ready_verification_status_blocks(status: &str) -> bool {
44    matches!(
45        status,
46        "needs_init" | "needs_import" | "needs_reconcile" | "git_branch_advanced"
47    )
48}
49
50// ---------------------------------------------------------------------------
51// Ready classification
52// ---------------------------------------------------------------------------
53
54/// Whether a thread preview has an integration target configured.
55pub fn has_integration_target(merge_relation: &str) -> bool {
56    merge_relation != "no_target"
57}
58
59/// Conflict-free and policy-clear: safe to mark ready / land.
60pub fn is_integration_clear(conflict_count: usize, blockers: &[String]) -> bool {
61    conflict_count == 0 && blockers.is_empty()
62}
63
64/// Inputs for classifying a ready-command outcome without performing I/O.
65#[derive(Debug, Clone, Copy)]
66pub struct ReadyDecisionInput<'a> {
67    pub merge_relation: &'a str,
68    pub captured: bool,
69    /// Whether the thread is already in [`repo::ThreadState::Ready`].
70    pub thread_already_ready: bool,
71    pub conflict_count: usize,
72    pub blockers: &'a [String],
73}
74
75/// Pure classification of readiness after preview / policy blockers are known.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ReadyDecision {
78    pub has_integration_target: bool,
79    /// Thread was already ready and no capture ran this invocation.
80    pub already_ready: bool,
81    /// Clean thread with no integration target configured.
82    pub ready_without_target: bool,
83    /// Conflict-free and no blockers (would mark Ready when a target exists).
84    pub integration_clear: bool,
85    /// Operator envelope should report `completed` (ready or no-target clean).
86    pub operator_completed: bool,
87}
88
89/// Classify ready outcome from preview facts (no I/O).
90pub fn classify_ready_decision(input: ReadyDecisionInput<'_>) -> ReadyDecision {
91    let has_target = has_integration_target(input.merge_relation);
92    let clear = is_integration_clear(input.conflict_count, input.blockers);
93    let already_ready = has_target && !input.captured && input.thread_already_ready && clear;
94    let ready_without_target = !has_target && clear;
95    ReadyDecision {
96        has_integration_target: has_target,
97        already_ready,
98        ready_without_target,
99        integration_clear: clear,
100        // Matches CLI: completed when no target is configured, or when the
101        // thread is (or would be) Ready after this invocation.
102        operator_completed: !has_target || clear,
103    }
104}
105
106/// Drop self-merge / land recommendations when the thread has no target.
107pub fn ready_report_recommended_action(
108    merge_relation: &str,
109    recommended_action: &str,
110) -> Option<String> {
111    if merge_relation == "no_target" {
112        return None;
113    }
114    non_empty_action(Some(recommended_action)).map(str::to_string)
115}
116
117/// Ready-scoped next-action selection (operation → thread fallback → publish).
118pub fn ready_scoped_next_action(
119    operation: Option<&RepositoryOperationStatus>,
120    remote_tracking: Option<&GitRemoteTrackingStatus>,
121    import_hint: Option<&GitImportGuidance>,
122    thread_action: Option<&str>,
123) -> String {
124    effective_next_action(
125        NextActionInput::default(operation, remote_tracking, import_hint, thread_action).ready(),
126    )
127}
128
129/// Whether land should squash the thread into one Git commit on write-through.
130pub fn should_squash_land(no_squash: bool, config_squash: bool) -> bool {
131    !no_squash && config_squash
132}
133
134// ---------------------------------------------------------------------------
135// Auto-land policy
136// ---------------------------------------------------------------------------
137
138/// Facts needed for auto-land policy without opening the object store.
139#[derive(Debug, Clone, Copy)]
140pub struct AutoLandPolicyInput {
141    pub agent_authored: bool,
142    pub confidence: Option<f32>,
143    pub tests_passed: Option<bool>,
144}
145
146/// Policy blockers that prevent automatic land (confidence / verification).
147pub fn auto_land_policy_blockers(input: AutoLandPolicyInput) -> Vec<String> {
148    let mut blockers = Vec::new();
149    if input.agent_authored
150        && let Some(confidence) = input.confidence
151        && confidence < AUTO_LAND_CONFIDENCE_THRESHOLD
152    {
153        blockers.push(format!(
154            "confidence {:.2} is below the auto-land threshold of {AUTO_LAND_CONFIDENCE_THRESHOLD:.2}",
155            confidence
156        ));
157    }
158    if matches!(input.tests_passed, Some(false)) {
159        blockers.push("verification summary reports failing tests".to_string());
160    }
161    blockers
162}
163
164/// Combine preview blockers with auto-land policy, honoring manual resolution.
165pub fn integration_blockers(
166    manual_resolution_current: bool,
167    preview_blockers: &[String],
168    policy: AutoLandPolicyInput,
169) -> Vec<String> {
170    let mut blockers = if manual_resolution_current {
171        Vec::new()
172    } else {
173        non_staleness_blockers(preview_blockers)
174    };
175    blockers.extend(auto_land_policy_blockers(policy));
176    blockers
177}
178
179/// Recovery breadcrumb for confidence / verification policy blockers.
180pub fn integration_blocker_recommended_action(
181    blockers: &[String],
182    scope_to_checkout: Option<&Path>,
183) -> Option<String> {
184    blockers
185        .iter()
186        .any(|blocker| {
187            blocker.starts_with("confidence ")
188                || blocker == "verification summary reports failing tests"
189        })
190        .then(|| auto_land_confidence_recovery_action(scope_to_checkout))
191}
192
193/// Scope the confidence recovery capture to the thread's checkout when needed.
194pub fn auto_land_confidence_recovery_action(scope_to_checkout: Option<&Path>) -> String {
195    match scope_to_checkout {
196        Some(path) => format!(
197            "heddle --repo {} {}",
198            shell_quote(&path.display().to_string()),
199            AUTO_LAND_CONFIDENCE_RECOVERY_ACTION
200                .strip_prefix("heddle ")
201                .expect("recovery action is a heddle command"),
202        ),
203        None => AUTO_LAND_CONFIDENCE_RECOVERY_ACTION.to_string(),
204    }
205}
206
207/// Returns the thread checkout when it is a real, distinct path from the
208/// current checkout (so recovery breadcrumbs must re-scope via `--repo`).
209pub fn recovery_scope_checkout(execution_path: &Path, current_checkout: &Path) -> Option<PathBuf> {
210    if execution_path.as_os_str().is_empty() {
211        return None;
212    }
213    let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
214    (canonical(execution_path) != canonical(current_checkout)).then(|| execution_path.to_path_buf())
215}
216
217// ---------------------------------------------------------------------------
218// Blocker classification / land preview surface
219// ---------------------------------------------------------------------------
220
221/// Heavy-impact lines are advisories for land, not hard blockers for sync.
222pub fn is_heavy_impact_advisory(blocker: &str) -> bool {
223    blocker.to_lowercase().contains("heavy-impact change")
224}
225
226/// Drop staleness and heavy-impact advisories from a blocker list.
227pub fn non_staleness_blockers(blockers: &[String]) -> Vec<String> {
228    blockers
229        .iter()
230        .filter(|blocker| {
231            !blocker.contains(" is stale against ") && !is_heavy_impact_advisory(blocker)
232        })
233        .cloned()
234        .collect()
235}
236
237/// Expand preview conflicts into land blockers, then sort/dedup.
238pub fn land_blockers_for_preview(
239    preview: &ThreadPreviewReport,
240    blockers: &[String],
241) -> Vec<String> {
242    let mut out = blockers.to_vec();
243    if preview.conflict_count > 0 {
244        out.push(format!(
245            "{} path conflict(s) need manual resolution",
246            preview.conflict_count
247        ));
248        out.extend(
249            preview
250                .conflicts
251                .iter()
252                .map(|path| format!("conflict: {path}")),
253        );
254    }
255    out.sort();
256    out.dedup();
257    out
258}
259
260/// Heavy-impact advisories for land (warnings, not hard blockers).
261pub fn land_warnings_for_preview(preview: &ThreadPreviewReport) -> Vec<String> {
262    let mut warnings = preview
263        .blockers
264        .iter()
265        .filter(|blocker| is_heavy_impact_advisory(blocker))
266        .cloned()
267        .collect::<Vec<_>>();
268    if warnings.is_empty() && !preview.heavy_impact_paths.is_empty() {
269        warnings.push(format!(
270            "Heavy-impact change: {} — review broader impact before merging",
271            preview.heavy_impact_paths.join(", ")
272        ));
273    }
274    warnings.sort();
275    warnings.dedup();
276    warnings
277}
278
279// ---------------------------------------------------------------------------
280// Land step accounting + post-integrate next action
281// ---------------------------------------------------------------------------
282
283/// Whether a land/preview blocker is the heavy-impact manual-review advisory.
284pub fn is_manual_review_blocker(blocker: &str) -> bool {
285    blocker.starts_with("Heavy-impact change:")
286}
287
288/// Human text for a land performed/skipped step token.
289pub fn land_text_step(step: &str) -> String {
290    match step {
291        "capture" => "saved".to_string(),
292        "sync" => "refreshed".to_string(),
293        "merge" => "merged".to_string(),
294        "checkpoint" => "committed".to_string(),
295        "capture(no changes)" => "no unsaved changes".to_string(),
296        "sync(current)" => "already refreshed".to_string(),
297        "merge(blocked)" => "merge blocked".to_string(),
298        "merge(already_integrated)" => "already landed".to_string(),
299        "checkpoint(not needed)" => "no Git commit needed".to_string(),
300        "checkpoint(not reached)" => "Git commit skipped because merge did not run".to_string(),
301        other => other.to_string(),
302    }
303}
304
305/// Scope a `heddle …` recommended action to an explicit `--repo` path.
306pub fn scope_action_to_repo(action: &str, repo_path: &str) -> String {
307    let Some(rest) = action.strip_prefix("heddle ") else {
308        return action.to_string();
309    };
310    if rest.starts_with("--repo ") || rest.starts_with("-R ") {
311        return action.to_string();
312    }
313    format!(
314        "heddle --repo {} {rest}",
315        quote_recommended_action_arg(repo_path)
316    )
317}
318
319/// Quote a recommended-action path/arg when it is not shell-safe bare.
320pub fn quote_recommended_action_arg(value: &str) -> String {
321    if !value.is_empty()
322        && value
323            .bytes()
324            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'))
325    {
326        value.to_string()
327    } else {
328        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
329    }
330}
331
332/// Ready summary labels from preview merge relation strings.
333pub fn ready_merge_type_label(result: &str) -> String {
334    match result {
335        "fast_forward" => "fast-forward".to_string(),
336        "already_integrated" => "already integrated".to_string(),
337        "no_target" => "none configured".to_string(),
338        other => other.replace('_', " "),
339    }
340}
341
342/// Ready status line for operator text (`clean` vs thread health).
343pub fn ready_status_summary(
344    merge_relation: &str,
345    blockers_empty: bool,
346    thread_health: &str,
347) -> String {
348    if merge_relation == "no_target" && blockers_empty {
349        "clean".to_string()
350    } else {
351        thread_health.replace('_', " ")
352    }
353}
354
355/// Integration column for ready summary.
356pub fn ready_integration_summary(merge_relation: &str) -> String {
357    match merge_relation {
358        "no_target" => "n/a (no integration target configured)".to_string(),
359        "not_checked" => "not checked (readiness checks did not run)".to_string(),
360        "blocked" => "not checked (repository verification is blocked)".to_string(),
361        _ => "configured".to_string(),
362    }
363}
364
365/// Freshness column for ready summary.
366pub fn ready_freshness_summary(merge_relation: &str, freshness: &str) -> String {
367    match merge_relation {
368        "no_target" => "n/a (no integration target configured)".to_string(),
369        "not_checked" => "not checked (readiness checks did not run)".to_string(),
370        "blocked" => "not checked (repository verification is blocked)".to_string(),
371        _ => freshness.replace('_', " "),
372    }
373}
374
375/// Merge-type column for ready summary.
376pub fn ready_merge_type_summary(merge_relation: &str) -> String {
377    match merge_relation {
378        "no_target" => "n/a (no integration target configured)".to_string(),
379        "not_checked" => "not checked (readiness checks did not run)".to_string(),
380        "blocked" => "not checked (repository verification is blocked)".to_string(),
381        other => ready_merge_type_label(other),
382    }
383}
384
385/// Steps that actually ran during land.
386pub fn land_performed_steps(
387    captured: bool,
388    synced: bool,
389    integrated: bool,
390    checkpointed: bool,
391) -> Vec<String> {
392    [
393        (captured, "capture"),
394        (synced, "sync"),
395        (integrated, "merge"),
396        (checkpointed, "checkpoint"),
397    ]
398    .into_iter()
399    .filter(|&(done, _step)| done)
400    .map(|(_done, step)| step.to_string())
401    .collect()
402}
403
404/// Steps skipped (with reason tokens) during land.
405pub fn land_skipped_steps(
406    captured: bool,
407    synced: bool,
408    integrated: bool,
409    checkpointed: bool,
410) -> Vec<String> {
411    [
412        (!captured, "capture(no changes)"),
413        (!synced, "sync(current)"),
414        (!integrated, "merge(blocked)"),
415        (!checkpointed && integrated, "checkpoint(not needed)"),
416        (!checkpointed && !integrated, "checkpoint(not reached)"),
417    ]
418    .into_iter()
419    .filter(|&(skipped, _step)| skipped)
420    .map(|(_skipped, step)| step.to_string())
421    .collect()
422}
423
424/// Next action after a successful sync (already current or refreshed).
425///
426/// Sync is its own job — refresh this thread onto its target — not land.
427/// Operation / remote / import still win. There is no `land --thread`
428/// fallback: a completed sync does not prescribe landing.
429pub fn sync_completed_next_action(
430    operation: Option<&RepositoryOperationStatus>,
431    remote_tracking: Option<&GitRemoteTrackingStatus>,
432    import_hint: Option<&GitImportGuidance>,
433) -> Option<String> {
434    non_empty_action(Some(&effective_next_action(NextActionInput::default(
435        operation,
436        remote_tracking,
437        import_hint,
438        None,
439    ))))
440    .map(str::to_string)
441}
442
443/// Whether sync has nothing left to refresh.
444///
445/// The no-op is freshness-current **or** the synthesized default
446/// checkout (a HEAD-attached ref with no ThreadManager record, often
447/// named `main`). A persisted managed thread with `target_thread: None`
448/// (created from detached HEAD) is **not** a no-op — it must reach
449/// `refresh_thread` and surface `missing_target_thread`.
450pub fn sync_is_already_current(freshness_is_current: bool, is_synthesized_checkout: bool) -> bool {
451    freshness_is_current || is_synthesized_checkout
452}
453
454/// Next action after a successful local land (push if trust says so, else cleanup).
455pub fn integrated_land_next_action(
456    integrated: bool,
457    trust_recommended_action: &str,
458) -> Option<String> {
459    if !integrated {
460        return None;
461    }
462    if trust_recommended_action == "heddle push" {
463        Some(trust_recommended_action.to_string())
464    } else {
465        Some("heddle thread cleanup --merged --dry-run".to_string())
466    }
467}
468
469/// Checkpoint / squash message for land write-through.
470///
471/// Precedence: explicit message → task/name for a squash → capture intent →
472/// task/name. Generated fallbacks avoid opaque identities; explicit messages
473/// are kept as supplied, subject to Git subject formatting.
474pub fn land_checkpoint_message(
475    explicit: Option<&str>,
476    prefer_land_subject: bool,
477    thread_name: &str,
478    intent: Option<&str>,
479    task: Option<&str>,
480) -> String {
481    if let Some(message) = explicit {
482        return git_subject_message(message);
483    }
484    let task = task.filter(|task| !subject_has_machine_identity(task));
485    let name = (!looks_like_machine_identity(thread_name)).then_some(thread_name);
486    let intent = intent.filter(|intent| !subject_has_machine_identity(intent));
487    let chosen = if prefer_land_subject {
488        task.or(name).or(intent)
489    } else {
490        intent.or(task).or(name)
491    };
492    git_subject_message(chosen.unwrap_or("Land thread"))
493}
494
495/// Keep Git's first line brief while retaining the full intent in the body.
496pub fn git_subject_message(message: &str) -> String {
497    let chosen = message.trim();
498    let first_line = chosen.lines().next().unwrap_or(chosen);
499    let subject = first_line.trim();
500    let detail = chosen[first_line.len()..].trim();
501    if subject.chars().count() <= 72 {
502        return if detail.is_empty() {
503            subject.to_string()
504        } else {
505            format!("{subject}\n\n{detail}")
506        };
507    }
508    let prefix: String = subject.chars().take(72).collect();
509    let short = prefix
510        .rsplit_once(char::is_whitespace)
511        .map(|(head, _)| head)
512        .filter(|head| !head.is_empty())
513        .unwrap_or(&prefix);
514    format!("{}\n\n{chosen}", short.trim_end())
515}
516
517/// Match tapestry's identity-label rule for values that should not be names.
518pub fn looks_like_machine_identity(value: &str) -> bool {
519    let text = value.trim();
520    if text.is_empty() {
521        return true;
522    }
523    let uuid = text.len() == 36
524        && text.bytes().enumerate().all(|(i, byte)| {
525            if [8, 13, 18, 23].contains(&i) {
526                byte == b'-'
527            } else {
528                byte.is_ascii_hexdigit()
529            }
530        });
531    let long_hex = text.len() >= 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit());
532    let prefixed = ["principal:", "agent:", "device:", "device-key:"]
533        .iter()
534        .any(|prefix| text.to_ascii_lowercase().starts_with(prefix));
535    let keyed = text.split_once(':').is_some_and(|(key, value)| {
536        key.len() >= 8
537            && key
538                .bytes()
539                .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
540            && value
541                .split(|ch: char| !ch.is_ascii_hexdigit())
542                .any(|part| part.len() >= 16)
543    });
544    uuid || long_hex || prefixed || keyed || text.starts_with("spool:git:")
545}
546
547/// Find a long hex identity or UUID even when a label is attached to it.
548pub fn machine_identity_span(text: &str) -> Option<(usize, usize)> {
549    for (start, ch) in text.char_indices() {
550        if !ch.is_ascii_hexdigit() {
551            continue;
552        }
553        let rest = &text[start..];
554        if rest.as_bytes().get(..36).is_some_and(|candidate| {
555            candidate.iter().copied().enumerate().all(|(index, byte)| {
556                if [8, 13, 18, 23].contains(&index) {
557                    byte == b'-'
558                } else {
559                    byte.is_ascii_hexdigit()
560                }
561            })
562        }) {
563            return Some((start, start + 36));
564        }
565        let hex_len = rest.bytes().take_while(u8::is_ascii_hexdigit).count();
566        if hex_len >= 16 {
567            return Some((start, start + hex_len));
568        }
569    }
570    None
571}
572
573/// Detect an opaque identity anywhere in the first line of a Git message.
574pub fn subject_has_machine_identity(message: &str) -> bool {
575    let subject = message.lines().next().unwrap_or_default();
576    subject.trim().is_empty()
577        || machine_identity_span(subject).is_some()
578        || subject
579            .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'))
580            .filter(|token| !token.is_empty())
581            .any(looks_like_machine_identity)
582}
583
584/// Whether a change id matches a short or full display form from operator text.
585pub fn state_id_matches_display(short: &str, full: &str, display: &str) -> bool {
586    short == display || full == display
587}
588
589/// Whether an oplog record advances HEAD/thread to a land merge target.
590pub fn op_targets_merge_state(op: &OpRecord, merge_state: &str) -> bool {
591    match op {
592        OpRecord::Snapshot { new_state, .. } => {
593            state_id_matches_display(&new_state.short(), &new_state.to_string_full(), merge_state)
594        }
595        OpRecord::Checkpoint { state, .. } => {
596            state_id_matches_display(&state.short(), &state.to_string_full(), merge_state)
597        }
598        OpRecord::Goto { target, .. } => {
599            state_id_matches_display(&target.short(), &target.to_string_full(), merge_state)
600        }
601        OpRecord::FastForward { post_target_id, .. } => state_id_matches_display(
602            &post_target_id.short(),
603            &post_target_id.to_string_full(),
604            merge_state,
605        ),
606        // Enumerated explicitly (no wildcard) so a new state-advancing variant
607        // must be considered here (heddle#354 r9).
608        OpRecord::ThreadCreate { .. }
609        | OpRecord::ThreadDelete { .. }
610        | OpRecord::ThreadUpdate { .. }
611        | OpRecord::Fork { .. }
612        | OpRecord::Collapse { .. }
613        | OpRecord::MarkerCreate { .. }
614        | OpRecord::MarkerDelete { .. }
615        | OpRecord::TransactionAbort { .. }
616        | OpRecord::EphemeralThreadCollapse { .. }
617        | OpRecord::ConflictResolved { .. }
618        | OpRecord::TransactionCommit { .. }
619        | OpRecord::Redact { .. }
620        | OpRecord::Purge { .. }
621        | OpRecord::GitCheckpoint { .. }
622        | OpRecord::RemoteThreadUpdate { .. }
623        | OpRecord::RemoteThreadDelete { .. }
624        | OpRecord::UndoRecoveryUpdate { .. }
625        | OpRecord::StateVisibilitySet { .. }
626        | OpRecord::StateVisibilityPromote { .. }
627        | OpRecord::EntryVisibilitySet { .. }
628        | OpRecord::HeadUpdate { .. } => false,
629    }
630}
631
632/// Convenience: match a [`StateId`] against operator display text.
633pub fn state_id_matches_op_display(id: &StateId, display: &str) -> bool {
634    state_id_matches_display(&id.short(), &id.to_string_full(), display)
635}
636
637#[cfg(test)]
638mod tests {
639    use repo::{OperationKind, OperationScope};
640
641    use super::*;
642    use crate::status::next_action as core_next_action;
643
644    fn bare_trust(status: &str) -> RepositoryVerificationState {
645        RepositoryVerificationState {
646            verified: false,
647            status: status.to_string(),
648            repository_mode: "native".to_string(),
649            heddle_initialized: true,
650            git_branch: None,
651            heddle_thread: None,
652            worktree_dirty: false,
653            worktree_state: "clean".to_string(),
654            import_state: "ok".to_string(),
655            mapping_state: "ok".to_string(),
656            remote_drift: "none".to_string(),
657            active_operation: None,
658            default_remote: None,
659            clone_verification: "not_applicable".to_string(),
660            machine_contract: "not_checked".to_string(),
661            machine_contract_coverage: crate::MachineContractCoverage::not_checked(),
662            workflow_status: "idle".to_string(),
663            workflow_summary: String::new(),
664            summary: status.to_string(),
665            recommended_action: "heddle verify".to_string(),
666            recommended_action_template: None,
667            recovery_commands: Vec::new(),
668            recovery_action_templates: Vec::new(),
669            checks: Vec::new(),
670        }
671    }
672
673    fn preview(merge_relation: &str) -> ThreadPreviewReport {
674        ThreadPreviewReport {
675            thread: "feature".to_string(),
676            thread_mode: "solid".to_string(),
677            thread_state: "ready".to_string(),
678            freshness: "current".to_string(),
679            task: None,
680            changed_paths: Vec::new(),
681            changed_path_count: 0,
682            impact_categories: Vec::new(),
683            heavy_impact_paths: Vec::new(),
684            merge_relation: merge_relation.to_string(),
685            conflicts: Vec::new(),
686            conflict_count: 0,
687            blockers: Vec::new(),
688            recommended_action: "heddle land --thread feature".to_string(),
689            recommended_action_template: None,
690            thread_health: "ready".to_string(),
691        }
692    }
693
694    #[test]
695    fn ready_preflight_blocks_setup_and_mapping_statuses() {
696        for status in [
697            "needs_init",
698            "needs_import",
699            "needs_reconcile",
700            "git_branch_advanced",
701        ] {
702            assert!(
703                ready_verification_preflight_blocks(&bare_trust(status)),
704                "{status} should block ready preflight"
705            );
706        }
707        assert!(!ready_verification_preflight_blocks(&bare_trust("clean")));
708        assert!(!ready_verification_preflight_blocks(&bare_trust(
709            "dirty_worktree"
710        )));
711    }
712
713    #[test]
714    fn ready_decision_classifies_already_ready_and_no_target() {
715        let clear = classify_ready_decision(ReadyDecisionInput {
716            merge_relation: "fast_forward",
717            captured: false,
718            thread_already_ready: true,
719            conflict_count: 0,
720            blockers: &[],
721        });
722        assert!(clear.already_ready);
723        assert!(clear.integration_clear);
724        assert!(clear.operator_completed);
725
726        let no_target = classify_ready_decision(ReadyDecisionInput {
727            merge_relation: "no_target",
728            captured: false,
729            thread_already_ready: false,
730            conflict_count: 0,
731            blockers: &[],
732        });
733        assert!(no_target.ready_without_target);
734        assert!(!no_target.has_integration_target);
735        assert!(no_target.operator_completed);
736
737        let blocked = classify_ready_decision(ReadyDecisionInput {
738            merge_relation: "conflicted",
739            captured: false,
740            thread_already_ready: false,
741            conflict_count: 1,
742            blockers: &["conflict".to_string()],
743        });
744        assert!(!blocked.integration_clear);
745        assert!(!blocked.operator_completed);
746    }
747
748    #[test]
749    fn ready_suppresses_action_without_target() {
750        assert_eq!(
751            ready_report_recommended_action("no_target", "heddle land --thread main"),
752            None
753        );
754        assert_eq!(
755            ready_report_recommended_action("fast_forward", "heddle land --thread feature"),
756            Some("heddle land --thread feature".to_string())
757        );
758    }
759
760    #[test]
761    fn ready_scoped_next_action_matches_core_matrix() {
762        let operation = RepositoryOperationStatus {
763            scope: OperationScope::Heddle,
764            kind: OperationKind::Merge,
765            in_progress: true,
766            state: "in_progress".to_string(),
767            message: "merge in progress".to_string(),
768            next_action: "heddle continue".to_string(),
769        };
770        let remote_ahead = GitRemoteTrackingStatus {
771            branch: "feature".to_string(),
772            upstream: "origin/feature".to_string(),
773            ahead: 1,
774            behind: 0,
775            local_oid: Some("local".to_string()),
776            upstream_oid: Some("upstream".to_string()),
777            upstream_is_undone_checkpoint: false,
778            message: String::new(),
779            next_action: String::new(),
780        };
781        let fallback = Some("heddle land --thread feature");
782        let scoped = ready_scoped_next_action(Some(&operation), None, None, fallback);
783        let core = core_next_action::effective_next_action(
784            core_next_action::NextActionInput::default(Some(&operation), None, None, fallback)
785                .ready(),
786        );
787        assert_eq!(scoped, core);
788        assert_eq!(scoped, "heddle continue");
789
790        let publish = ready_scoped_next_action(None, Some(&remote_ahead), None, None);
791        assert_eq!(
792            publish,
793            core_next_action::effective_next_action(
794                core_next_action::NextActionInput::default(None, Some(&remote_ahead), None, None,)
795                    .ready(),
796            )
797        );
798    }
799
800    #[test]
801    fn auto_land_policy_blocks_low_confidence_and_failing_tests() {
802        let blockers = auto_land_policy_blockers(AutoLandPolicyInput {
803            agent_authored: true,
804            confidence: Some(0.40),
805            tests_passed: Some(false),
806        });
807        assert_eq!(
808            blockers,
809            vec![
810                "confidence 0.40 is below the auto-land threshold of 0.75".to_string(),
811                "verification summary reports failing tests".to_string(),
812            ]
813        );
814        assert!(
815            auto_land_policy_blockers(AutoLandPolicyInput {
816                agent_authored: false,
817                confidence: Some(0.10),
818                tests_passed: Some(true),
819            })
820            .is_empty()
821        );
822    }
823
824    #[test]
825    fn confidence_blocker_recovery_scopes_to_thread_checkout() {
826        let blockers = vec!["confidence 0.40 is below the auto-land threshold of 0.75".to_string()];
827        let action = integration_blocker_recommended_action(
828            &blockers,
829            Some(Path::new("/work/threads/agent-thread")),
830        )
831        .expect("confidence blocker must yield recovery");
832        assert_eq!(
833            action,
834            "heddle --repo /work/threads/agent-thread capture -m \"...\" --confidence <confidence>"
835        );
836
837        let unscoped =
838            integration_blocker_recommended_action(&blockers, None).expect("unscoped recovery");
839        assert_eq!(unscoped, AUTO_LAND_CONFIDENCE_RECOVERY_ACTION);
840
841        assert!(
842            integration_blocker_recommended_action(
843                &["3 path conflict(s) need manual resolution".to_string()],
844                None
845            )
846            .is_none()
847        );
848    }
849
850    #[test]
851    fn non_staleness_drops_stale_and_heavy_impact() {
852        let blockers = vec![
853            "Thread 'agent-thread' is stale against 'main'".to_string(),
854            "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
855                .to_string(),
856            "confidence 0.40 is below the auto-land threshold of 0.75".to_string(),
857        ];
858        assert_eq!(
859            non_staleness_blockers(&blockers),
860            vec!["confidence 0.40 is below the auto-land threshold of 0.75".to_string()]
861        );
862    }
863
864    #[test]
865    fn land_warnings_surface_heavy_impact_review() {
866        let mut report = preview("would_merge");
867        report.heavy_impact_paths = vec!["crates/wire/src/lib.rs".to_string()];
868        report.blockers = vec![
869            "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
870                .to_string(),
871        ];
872        assert_eq!(
873            land_warnings_for_preview(&report),
874            vec![
875                "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
876                    .to_string()
877            ]
878        );
879    }
880
881    #[test]
882    fn land_step_accounting_and_next_action() {
883        assert_eq!(
884            land_performed_steps(true, false, true, true),
885            vec!["capture", "merge", "checkpoint"]
886        );
887        assert!(land_skipped_steps(true, true, true, true).is_empty());
888        assert_eq!(
889            integrated_land_next_action(true, "heddle push"),
890            Some("heddle push".to_string())
891        );
892        assert_eq!(
893            integrated_land_next_action(true, "heddle push"),
894            Some("heddle push".to_string())
895        );
896        assert_eq!(integrated_land_next_action(false, "heddle push"), None);
897    }
898
899    #[test]
900    fn sync_completed_next_action_is_not_land_thread() {
901        assert_eq!(sync_completed_next_action(None, None, None), None);
902
903        let operation = RepositoryOperationStatus {
904            scope: OperationScope::Heddle,
905            kind: OperationKind::Merge,
906            in_progress: true,
907            state: "in_progress".to_string(),
908            message: "merge in progress".to_string(),
909            next_action: "heddle continue".to_string(),
910        };
911        assert_eq!(
912            sync_completed_next_action(Some(&operation), None, None),
913            Some("heddle continue".to_string())
914        );
915
916        assert!(sync_is_already_current(true, false));
917        assert!(sync_is_already_current(false, true));
918        assert!(!sync_is_already_current(false, false));
919    }
920
921    #[test]
922    fn recovery_scope_checkout_distinguishes_isolated_from_in_thread() {
923        assert_eq!(
924            recovery_scope_checkout(
925                Path::new("/work/threads/agent-thread"),
926                Path::new("/work/parent"),
927            ),
928            Some(PathBuf::from("/work/threads/agent-thread")),
929        );
930        assert_eq!(
931            recovery_scope_checkout(
932                Path::new("/work/threads/agent-thread"),
933                Path::new("/work/threads/agent-thread"),
934            ),
935            None,
936        );
937        assert_eq!(
938            recovery_scope_checkout(Path::new(""), Path::new("/work/parent")),
939            None,
940        );
941    }
942
943    #[test]
944    fn should_squash_respects_no_squash_and_config() {
945        assert!(should_squash_land(false, true));
946        assert!(!should_squash_land(true, true));
947        assert!(!should_squash_land(false, false));
948    }
949
950    #[test]
951    fn land_checkpoint_message_precedence() {
952        assert_eq!(
953            land_checkpoint_message(Some("explicit"), false, "t", Some("intent"), Some("task")),
954            "explicit"
955        );
956        assert_eq!(
957            land_checkpoint_message(Some("  "), true, "t", Some("intent"), None),
958            ""
959        );
960        assert_eq!(
961            land_checkpoint_message(None, false, "t", Some("intent"), Some("task")),
962            "intent"
963        );
964        assert_eq!(
965            land_checkpoint_message(None, false, "t", None, Some("task")),
966            "task"
967        );
968        assert_eq!(land_checkpoint_message(None, false, "t", None, None), "t");
969    }
970
971    #[test]
972    fn land_checkpoint_subject_uses_task_instead_of_hex_thread_id() {
973        let id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
974        let subject = land_checkpoint_message(
975            None,
976            true,
977            id,
978            Some("First capture intent"),
979            Some("Improve search results"),
980        );
981        assert_eq!(subject, "Improve search results");
982        assert!(!subject.contains(id));
983        assert_eq!(
984            land_checkpoint_message(None, true, id, Some("Add search index"), None),
985            "Add search index"
986        );
987    }
988
989    #[test]
990    fn land_checkpoint_honors_explicit_message_with_64_hex_identity() {
991        let id = "a".repeat(64);
992        let explicit = format!("Revert {id}");
993        let subject = land_checkpoint_message(
994            Some(&explicit),
995            true,
996            "search",
997            None,
998            Some("Improve search results"),
999        );
1000        assert_eq!(subject.lines().next(), Some(explicit.as_str()));
1001        assert_eq!(subject, explicit);
1002    }
1003
1004    #[test]
1005    fn land_checkpoint_long_task_keeps_subject_short_and_detail_in_body() {
1006        let task = "Improve search ranking so results from multiple sources stay relevant and readable for people";
1007        let message = land_checkpoint_message(None, true, "feature/search", None, Some(task));
1008        let subject = message.lines().next().unwrap_or_default();
1009        assert!(subject.chars().count() <= 72, "{message}");
1010        assert!(message.contains(&format!("\n\n{task}")), "{message}");
1011    }
1012
1013    #[test]
1014    fn state_id_matches_short_or_full() {
1015        assert!(state_id_matches_display("abc", "abcdef", "abc"));
1016        assert!(state_id_matches_display("abc", "abcdef", "abcdef"));
1017        assert!(!state_id_matches_display("abc", "abcdef", "zzz"));
1018    }
1019
1020    #[test]
1021    fn land_text_scope_and_manual_review() {
1022        assert_eq!(land_text_step("capture"), "saved");
1023        assert_eq!(land_text_step("merge(blocked)"), "merge blocked");
1024        assert_eq!(
1025            land_text_step("merge(already_integrated)"),
1026            "already landed"
1027        );
1028        assert_eq!(
1029            land_text_step("checkpoint(not reached)"),
1030            "Git commit skipped because merge did not run"
1031        );
1032        assert!(is_manual_review_blocker("Heavy-impact change: Cargo.lock"));
1033        assert!(!is_manual_review_blocker("stale"));
1034        assert_eq!(
1035            scope_action_to_repo("heddle land main", "/tmp/repo"),
1036            "heddle --repo /tmp/repo land main"
1037        );
1038        assert_eq!(ready_merge_type_label("fast_forward"), "fast-forward");
1039        assert_eq!(
1040            ready_merge_type_label("already_integrated"),
1041            "already integrated"
1042        );
1043        assert_eq!(ready_merge_type_label("no_target"), "none configured");
1044    }
1045}