Skip to main content

ito_core/implementation_readiness/
mod.rs

1//! Main-first proposal authority and implementation readiness evaluation.
2
3mod git;
4mod render_source;
5mod tree_validation;
6mod types;
7
8use ito_config::types::{ItoConfig, ProposalIntegrationMode};
9
10pub use render_source::{AuthoritativeChangeSource, AuthoritativeSourceError};
11pub use types::{
12    AuthorityEvidence, AuthoritySnapshot, ReadinessCondition, ReadinessPhase, ReadinessReport,
13    ReadinessRequest,
14};
15
16use self::git::{CheckoutState, ReadinessGit, SystemReadinessGit};
17use self::tree_validation::{PrepareFailureKind, validate_authoritative_change};
18
19/// Evaluate main-first readiness using the production Git object adapter.
20pub fn evaluate_readiness(request: &ReadinessRequest, config: &ItoConfig) -> ReadinessReport {
21    evaluate_readiness_with_git(request, config, &SystemReadinessGit)
22}
23
24/// Materialize apply inputs from the immutable authority snapshot captured by
25/// a successful prepare report.
26pub fn materialize_authoritative_change(
27    prepare: &ReadinessReport,
28    repository_root: &std::path::Path,
29    guidance_artifacts: &[&str],
30) -> Result<AuthoritativeChangeSource, AuthoritativeSourceError> {
31    render_source::materialize_authoritative_change(
32        prepare,
33        repository_root,
34        guidance_artifacts,
35        &SystemReadinessGit,
36    )
37}
38
39/// Render a readiness report for human-facing CLI and runtime failures.
40#[must_use]
41pub fn render_readiness_text(report: &ReadinessReport) -> String {
42    let status = if report.ready { "ready" } else { "not ready" };
43    let mut lines = vec![
44        format!(
45            "Change '{}' is {status} for {:?}.",
46            report.change_id, report.phase
47        ),
48        format!("Integration mode: {}", report.authority.integration_mode),
49        format!(
50            "Authority ref: {}",
51            report
52                .authority
53                .target_ref
54                .as_deref()
55                .unwrap_or("unresolved")
56        ),
57        format!(
58            "Authority OID: {}",
59            report.authority.oid.as_deref().unwrap_or("unresolved")
60        ),
61        format!(
62            "Proposal integration OID: {}",
63            report
64                .proposal_integration_oid
65                .as_deref()
66                .unwrap_or("unresolved")
67        ),
68        String::new(),
69        "Conditions:".to_string(),
70    ];
71    for condition in &report.conditions {
72        let mark = if condition.passed { "PASS" } else { "FAIL" };
73        lines.push(format!(
74            "  [{mark}] {}: {}",
75            condition.code, condition.message
76        ));
77        if let Some(remediation) = &condition.remediation {
78            lines.push(format!("         Fix: {remediation}"));
79        }
80    }
81    lines.join("\n")
82}
83
84/// Evaluate execute readiness from one already-successful prepare snapshot.
85///
86/// This preserves the authority and integration OIDs captured before a new
87/// worktree is created, so a concurrently moving target ref cannot change the
88/// base or proof halfway through that operation.
89pub fn evaluate_execute_from_prepare(
90    prepare: &ReadinessReport,
91    request: &ReadinessRequest,
92    config: &ItoConfig,
93) -> ReadinessReport {
94    evaluate_execute_from_prepare_with_git(prepare, request, config, &SystemReadinessGit)
95}
96
97fn evaluate_execute_from_prepare_with_git(
98    prepare: &ReadinessReport,
99    request: &ReadinessRequest,
100    config: &ItoConfig,
101    git: &dyn ReadinessGit,
102) -> ReadinessReport {
103    let mut report = prepare.clone();
104    report.phase = ReadinessPhase::Execute;
105    report.ready = false;
106    let valid_prepare = prepare.phase == ReadinessPhase::Prepare
107        && prepare.ready
108        && prepare.change_id == request.change_id
109        && request.phase == ReadinessPhase::Execute
110        && prepare.authority.integration_mode == config.changes.proposal.integration_mode;
111    let Some(integration_oid) = prepare.proposal_integration_oid.as_deref() else {
112        report.conditions.push(ReadinessCondition::failed(
113            "prepare_snapshot",
114            "The prepare report does not contain a proposal integration commit.",
115            "Run prepare readiness successfully immediately before creating the implementation worktree.",
116        ));
117        return report;
118    };
119    if !valid_prepare || prepare.authority_snapshot().is_none() {
120        report.conditions.push(ReadinessCondition::failed(
121            "prepare_snapshot",
122            "The supplied prepare report is not a successful compatible authority snapshot for this execute request.",
123            "Run prepare readiness for this exact change and integration mode, then retry without substituting another report.",
124        ));
125        return report;
126    }
127
128    evaluate_execute_conditions(&mut report, request, config, git, integration_oid);
129    report
130}
131
132pub(crate) fn evaluate_readiness_with_git(
133    request: &ReadinessRequest,
134    config: &ItoConfig,
135    git: &dyn ReadinessGit,
136) -> ReadinessReport {
137    let mut report = evaluate_authority_with_git(request, config, git);
138    if !report.ready {
139        return report;
140    }
141    report.ready = false;
142
143    let snapshot = report
144        .authority_snapshot()
145        .expect("successful authority resolution returns a complete snapshot");
146    let canonical_change_id = match resolve_authoritative_change_target(git, request, &snapshot) {
147        Ok(change_id) => change_id,
148        Err((message, remediation)) => {
149            report.conditions.push(ReadinessCondition::failed(
150                "change_target",
151                message,
152                remediation,
153            ));
154            return report;
155        }
156    };
157    report.change_id = canonical_change_id.clone();
158    report.conditions.push(ReadinessCondition::passed(
159        "change_target",
160        format!(
161            "Resolved change target '{}' to authoritative change '{}'.",
162            request.change_id, canonical_change_id
163        ),
164    ));
165    let mut request = request.clone();
166    request.change_id = canonical_change_id;
167    let proof = match validate_authoritative_change(git, &request, &snapshot) {
168        Ok(proof) => proof,
169        Err(failure) => {
170            let remediation = format!(
171                "Correct '{}' in the reviewed proposal, integrate the fix through {}, and retry readiness.",
172                failure.path, snapshot.integration_mode
173            );
174            let condition = match failure.kind {
175                PrepareFailureKind::Artifacts => ReadinessCondition::failed_artifact(
176                    format!("{}: {}", failure.path, failure.message),
177                    remediation,
178                    failure.path,
179                ),
180                PrepareFailureKind::Validation => ReadinessCondition::failed_validation(
181                    failure.message,
182                    remediation,
183                    failure.path,
184                    failure.validator_code,
185                ),
186            };
187            report.conditions.push(condition);
188            return report;
189        }
190    };
191    report.conditions.push(ReadinessCondition::passed(
192        "authoritative_artifacts",
193        format!(
194            "Loaded {} apply prerequisite file(s) for schema '{}' from authority commit '{}'.",
195            proof.artifact_paths.len(),
196            proof.schema_name,
197            snapshot.oid
198        ),
199    ));
200    report.conditions.push(ReadinessCondition::passed(
201        "authoritative_validation",
202        format!(
203            "Authoritative proposal files passed strict '{}' schema validation.",
204            proof.schema_name
205        ),
206    ));
207
208    let marker_path = format!(".ito/changes/{}/.ito.yaml", request.change_id);
209    let integration_oid = match git.find_introduction_commit(
210        &request.repository_root,
211        &snapshot.oid,
212        &marker_path,
213    ) {
214        Ok(oid) => {
215            report.proposal_integration_oid = Some(oid.clone());
216            report.conditions.push(ReadinessCondition::passed(
217                "proposal_integration",
218                format!(
219                    "Target first-parent history introduced the proposal marker at commit '{oid}'."
220                ),
221            ));
222            oid
223        }
224        Err(error) => {
225            report.conditions.push(ReadinessCondition::failed(
226                "proposal_integration",
227                format!("Cannot prove proposal integration in target history: {error}"),
228                format!(
229                    "Integrate the reviewed proposal into '{}' with complete history available, then retry.",
230                    snapshot.target_ref
231                ),
232            ));
233            return report;
234        }
235    };
236
237    if request.phase == ReadinessPhase::Prepare {
238        report.ready = true;
239        return report;
240    }
241
242    evaluate_execute_conditions(&mut report, &request, config, git, &integration_oid);
243    report
244}
245
246fn resolve_authoritative_change_target(
247    git: &dyn ReadinessGit,
248    request: &ReadinessRequest,
249    snapshot: &AuthoritySnapshot,
250) -> Result<String, (String, String)> {
251    let entries = git
252        .list_tree(&request.repository_root, &snapshot.oid, ".ito/changes")
253        .map_err(|error| {
254            (
255                format!("Cannot list authoritative changes: {error}"),
256                "Make the accepted proposal tree available locally and retry.".to_string(),
257            )
258        })?;
259    let mut change_ids = entries
260        .iter()
261        .filter(|entry| entry.path.ends_with("/.ito.yaml"))
262        .filter_map(|entry| {
263            entry
264                .path
265                .strip_prefix(".ito/changes/")
266                .and_then(|path| path.strip_suffix("/.ito.yaml"))
267        })
268        .filter(|change_id| !change_id.is_empty() && !change_id.contains('/'))
269        .map(ToOwned::to_owned)
270        .collect::<Vec<_>>();
271    change_ids.sort();
272    change_ids.dedup();
273
274    let input = request.change_id.trim();
275    if let Some(exact) = change_ids
276        .iter()
277        .find(|change_id| change_id.as_str() == input)
278    {
279        return Ok(exact.clone());
280    }
281    let matches = change_ids
282        .into_iter()
283        .filter(|change_id| change_id.starts_with(input))
284        .collect::<Vec<_>>();
285    if matches.len() == 1 {
286        return Ok(matches[0].clone());
287    }
288    if matches.is_empty() {
289        return Err((
290            format!(
291                "Change target '{}' was not found in authority commit '{}'.",
292                request.change_id, snapshot.oid
293            ),
294            "Integrate the reviewed proposal into the authoritative target branch, or use its canonical change ID."
295                .to_string(),
296        ));
297    }
298    Err((
299        format!(
300            "Change target '{}' is ambiguous in authority commit '{}'. Matches: {}.",
301            request.change_id,
302            snapshot.oid,
303            matches.join(", ")
304        ),
305        "Use a longer prefix or the full canonical change ID.".to_string(),
306    ))
307}
308
309fn evaluate_execute_conditions(
310    report: &mut ReadinessReport,
311    request: &ReadinessRequest,
312    config: &ItoConfig,
313    git: &dyn ReadinessGit,
314    integration_oid: &str,
315) {
316    let Some(checkout_path) = request.current_checkout.as_deref() else {
317        report.conditions.push(ReadinessCondition::failed(
318            "checkout_identity",
319            "Execute readiness requires a current implementation checkout.",
320            "Run execute preflight from the dedicated change worktree, or provide that checkout explicitly.",
321        ));
322        return;
323    };
324    let repository_state = match git.inspect_checkout(&request.repository_root) {
325        Ok(state) => state,
326        Err(error) => {
327            report.conditions.push(ReadinessCondition::failed(
328                "checkout_identity",
329                format!("Cannot inspect the authority repository identity: {error}"),
330                "Run readiness from a valid Git repository and retry.",
331            ));
332            return;
333        }
334    };
335    let checkout_state = match git.inspect_checkout(checkout_path) {
336        Ok(state) => state,
337        Err(error) => {
338            report.conditions.push(ReadinessCondition::failed(
339                "checkout_identity",
340                format!(
341                    "Cannot inspect implementation checkout '{}': {error}",
342                    checkout_path.display()
343                ),
344                "Select a valid dedicated Git worktree for this change and retry.",
345            ));
346            return;
347        }
348    };
349
350    let same_repository = repository_state.common_dir == checkout_state.common_dir;
351    let ancestry_passed = if same_repository {
352        match git.is_ancestor(
353            &checkout_state.root,
354            integration_oid,
355            &checkout_state.head_oid,
356        ) {
357            Ok(true) => {
358                report.conditions.push(ReadinessCondition::passed(
359                    "implementation_ancestry",
360                    format!(
361                        "Implementation HEAD '{}' descends from proposal integration commit '{}'.",
362                        checkout_state.head_oid, integration_oid
363                    ),
364                ));
365                true
366            }
367            Ok(false) => {
368                report.conditions.push(ReadinessCondition::failed(
369                    "implementation_ancestry",
370                    format!(
371                        "Implementation HEAD '{}' does not contain proposal integration commit '{}'.",
372                        checkout_state.head_oid, integration_oid
373                    ),
374                    "Recreate the change worktree from the verified authority commit, or rebase/merge the accepted proposal history before implementation.",
375                ));
376                false
377            }
378            Err(error) => {
379                report.conditions.push(ReadinessCondition::failed(
380                    "implementation_ancestry",
381                    format!("Cannot inspect implementation ancestry: {error}"),
382                    "Make the complete proposal and implementation history available locally, then retry.",
383                ));
384                false
385            }
386        }
387    } else {
388        report.conditions.push(ReadinessCondition::failed(
389            "implementation_ancestry",
390            "The selected checkout belongs to a different Git repository, so proposal ancestry cannot be proven.",
391            "Select a dedicated change worktree from the authoritative repository and retry.",
392        ));
393        false
394    };
395
396    let identity_passed =
397        evaluate_checkout_identity(report, request, config, &checkout_state, same_repository);
398    report.ready = ancestry_passed && identity_passed;
399}
400
401fn evaluate_checkout_identity(
402    report: &mut ReadinessReport,
403    request: &ReadinessRequest,
404    config: &ItoConfig,
405    checkout: &CheckoutState,
406    same_repository: bool,
407) -> bool {
408    let branch = checkout.branch.as_deref();
409    let failure = if !same_repository {
410        Some("The selected checkout is not a worktree of the authoritative repository.".to_string())
411    } else if checkout.is_bare {
412        Some(
413            "The selected checkout is a bare control repository, not an implementation worktree."
414                .to_string(),
415        )
416    } else if branch == Some(config.worktrees.default_branch.as_str()) {
417        Some(format!(
418            "The selected checkout is the authoritative target branch '{}', not a dedicated implementation worktree.",
419            config.worktrees.default_branch
420        ))
421    } else if !crate::worktree_validate::checkout_matches_change_id(
422        &checkout.root,
423        branch,
424        &request.change_id,
425    ) {
426        Some(format!(
427            "Checkout '{}'{} is not associated with full change ID '{}'.",
428            checkout.root.display(),
429            branch
430                .map(|name| format!(" on branch '{name}'"))
431                .unwrap_or_default(),
432            request.change_id
433        ))
434    } else {
435        None
436    };
437
438    if let Some(message) = failure {
439        report.conditions.push(ReadinessCondition::failed(
440            "checkout_identity",
441            message,
442            format!(
443                "Use the dedicated '{}' worktree (a suffixed review worktree is also accepted) and retry.",
444                request.change_id
445            ),
446        ));
447        return false;
448    }
449
450    report.conditions.push(ReadinessCondition::passed(
451        "checkout_identity",
452        format!(
453            "Checkout '{}'{} is associated with change '{}'.",
454            checkout.root.display(),
455            branch
456                .map(|name| format!(" on branch '{name}'"))
457                .unwrap_or_default(),
458            request.change_id
459        ),
460    ));
461    true
462}
463
464/// Resolve the configured proposal authority to an immutable commit snapshot.
465///
466/// This is the production entry point. It performs no durable writes. When
467/// `request.refresh_authority` is true in pull-request mode, the one configured
468/// upstream target ref is fetched before its commit is resolved.
469#[cfg(test)]
470pub(crate) fn evaluate_authority(
471    request: &ReadinessRequest,
472    config: &ItoConfig,
473) -> ReadinessReport {
474    evaluate_authority_with_git(request, config, &SystemReadinessGit)
475}
476
477/// Resolve proposal authority using an injected Git boundary.
478///
479/// The authority ref is resolved exactly once. All later readiness phases must
480/// consume the returned OID rather than resolving the mutable ref again.
481pub(crate) fn evaluate_authority_with_git(
482    request: &ReadinessRequest,
483    config: &ItoConfig,
484    git: &dyn ReadinessGit,
485) -> ReadinessReport {
486    let mode = config.changes.proposal.integration_mode;
487    let local_target_ref = format!("refs/heads/{}", config.worktrees.default_branch);
488    let mut report = ReadinessReport::new(request, mode);
489
490    let upstream = match mode {
491        ProposalIntegrationMode::DirectMerge => {
492            report.authority.target_ref = Some(local_target_ref.clone());
493            report.conditions.push(ReadinessCondition::passed(
494                "authority_ref",
495                format!("Using local target ref '{local_target_ref}' for direct-merge authority."),
496            ));
497            None
498        }
499        ProposalIntegrationMode::PullRequest => {
500            match git.tracked_upstream(&request.repository_root, &local_target_ref) {
501                Ok(upstream) => {
502                    report.authority.target_ref = Some(upstream.tracking_ref.clone());
503                    report.conditions.push(ReadinessCondition::passed(
504                        "authority_ref",
505                        format!(
506                            "Using tracked upstream ref '{}' for pull-request authority.",
507                            upstream.tracking_ref
508                        ),
509                    ));
510                    Some(upstream)
511                }
512                Err(error) => {
513                    report.conditions.push(ReadinessCondition::failed(
514                        "authority_ref",
515                        format!(
516                            "Cannot resolve the tracked upstream for target ref '{local_target_ref}': {error}"
517                        ),
518                        "Configure and fetch the target branch's tracked upstream, or explicitly set changes.proposal.integration_mode to direct_merge if this repository does not use a pull_request workflow.",
519                    ));
520                    return report;
521                }
522            }
523        }
524    };
525
526    if request.refresh_authority {
527        if let Some(upstream) = upstream.as_ref() {
528            match git.refresh_upstream(&request.repository_root, upstream) {
529                Ok(()) => report.conditions.push(ReadinessCondition::passed(
530                    "authority_refresh",
531                    format!("Refreshed authority ref '{}'.", upstream.tracking_ref),
532                )),
533                Err(error) => {
534                    report.conditions.push(ReadinessCondition::failed(
535                        "authority_refresh",
536                        format!(
537                            "Cannot refresh authority ref '{}': {error}",
538                            upstream.tracking_ref
539                        ),
540                        format!(
541                            "Check access to remote '{}' and retry the refreshed preflight.",
542                            upstream.remote
543                        ),
544                    ));
545                    return report;
546                }
547            }
548        } else {
549            report.conditions.push(ReadinessCondition::passed(
550                "authority_refresh",
551                "Direct-merge authority is local and does not require a remote refresh.",
552            ));
553        }
554    }
555
556    let target_ref = report
557        .authority
558        .target_ref
559        .as_deref()
560        .expect("successful authority selection always records a target ref");
561    match git.resolve_commit(&request.repository_root, target_ref) {
562        Ok(oid) => {
563            report.authority.oid = Some(oid.clone());
564            report.conditions.push(ReadinessCondition::passed(
565                "authority_oid",
566                format!("Resolved authority ref '{target_ref}' to commit '{oid}'."),
567            ));
568            report.ready = true;
569        }
570        Err(error) => report.conditions.push(ReadinessCondition::failed(
571            "authority_oid",
572            format!("Cannot resolve authority ref '{target_ref}' to a commit: {error}"),
573            match mode {
574                ProposalIntegrationMode::PullRequest => {
575                    format!("Fetch the configured upstream target ref '{target_ref}' and retry.")
576                }
577                ProposalIntegrationMode::DirectMerge => format!(
578                    "Create or update the local target branch '{}' and retry.",
579                    config.worktrees.default_branch
580                ),
581            },
582        )),
583    }
584
585    report
586}
587
588#[cfg(test)]
589#[path = "implementation_readiness_tests.rs"]
590mod tests;