use anyhow::Result;
use std::path::Path;
use crate::sdd::change::git_native::{
ChangeGitBinding, current_branch, is_default_branch, read_binding, run_git,
};
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::spec::validation::{
ChangeStage, ProposalFrontmatter, check_proposal_frontmatter, determine_stage,
};
pub const SPECS_PATHSPEC: &str = "llmanspec/specs";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpecsLandingStatus {
pub specs_landed: bool,
pub skip_specs_landing: bool,
pub ready_to_implement: bool,
pub stage: ChangeStage,
pub detail: Option<String>,
}
impl SpecsLandingStatus {
pub fn not_ready_message(&self, change_id: &str) -> String {
if self.ready_to_implement {
return String::new();
}
if self.stage != ChangeStage::Full {
return format!(
"change `{change_id}` stage={} (not full); run `change start` or `change attach` first. Skill: llman-sdd-propose.",
self.stage.as_str()
);
}
if let Some(detail) = &self.detail {
return detail.clone();
}
format!(
"specs not landed: change `{change_id}` is Full but has no llmanspec/specs/ diff on its bound branch. \
Edit live specs on the bound branch and commit (or set skip_specs_landing: true if no contract change). \
Skill: llman-sdd-propose — do NOT re-run change start if already attached. Apply when show --json readyToImplement=true (llman-sdd-apply)."
)
}
}
pub fn evaluate_specs_landing(root: &Path, change_dir: &Path) -> SpecsLandingStatus {
let stage = determine_stage(change_dir);
let skip = read_skip_specs_landing(change_dir);
let binding = read_binding_for_change(root, change_dir);
if stage != ChangeStage::Full {
return SpecsLandingStatus {
specs_landed: false,
skip_specs_landing: skip,
ready_to_implement: false,
stage,
detail: None,
};
}
let Some(binding) = binding else {
let msg = "change is Full but Git binding unreadable; re-run `llman sdd change attach` on the feature branch. Skill: llman-sdd-propose.".to_string();
return SpecsLandingStatus {
specs_landed: false,
skip_specs_landing: skip,
ready_to_implement: skip,
stage,
detail: Some(msg),
};
};
let (landed, detail) = match specs_diff_nonempty(root, &binding) {
Ok(true) => (true, None),
Ok(false) => (
false,
Some(format!(
"specs not landed: change bound to `{}` but `{}...{}` has no changes under `{SPECS_PATHSPEC}/`. \
Edit live specs on that branch and commit. Skill: llman-sdd-propose (land specs) — do NOT re-run change start if already attached. \
Apply only when `llman sdd show <id> --json` has readyToImplement=true (llman-sdd-apply). \
Or set `skip_specs_landing: true` in proposal frontmatter if this change has no live contract edits.",
binding.branch, binding.base_sha, binding.branch
)),
),
Err(err) => (
false,
Some(format!(
"specs landing check failed for branch `{}` (base {}): {err}. \
Ensure the bound branch exists locally; recover by checkout/recreate then `change attach --force` if needed. Skill: llman-sdd-propose.",
binding.branch, binding.base_sha
)),
),
};
SpecsLandingStatus {
specs_landed: landed,
skip_specs_landing: skip,
ready_to_implement: landed || skip,
stage,
detail: if landed || skip { None } else { detail },
}
}
fn read_skip_specs_landing(change_dir: &Path) -> bool {
let (issues, fm) = check_proposal_frontmatter(change_dir, &[], &[], false);
let _ = issues;
fm.skip_specs_landing
}
fn read_binding_for_change(root: &Path, change_dir: &Path) -> Option<ChangeGitBinding> {
let name = change_dir.file_name()?.to_str()?;
read_binding(root, name).ok().flatten()
}
pub fn specs_diff_nonempty(root: &Path, binding: &ChangeGitBinding) -> Result<bool> {
let range = format!("{}...{}", binding.base_sha, binding.branch);
let out = run_git(
root,
&[
"diff",
"--name-only",
"--find-renames",
&range,
"--",
SPECS_PATHSPEC,
],
)?;
Ok(out
.lines()
.any(|l| l.trim().ends_with(".feature") && !l.trim().is_empty()))
}
pub fn warn_dirty_specs_on_default_branch(root: &Path) -> Option<String> {
let branch = current_branch(root).ok()?;
if !is_default_branch(root, &branch).ok()? {
return None;
}
let status = run_git(root, &["status", "--porcelain", "--", SPECS_PATHSPEC]).ok()?;
if status.lines().all(|l| l.trim().is_empty()) {
return None;
}
Some(format!(
"live specs dirty on default branch `{branch}`: do not commit unimplemented contracts to the default branch. \
Switch to the change's bound `sdd/<id>` branch (or `llman sdd change start <id>`) before editing `{SPECS_PATHSPEC}/`. \
See AGENTS.md Specs landing. Skill: llman-sdd-propose."
))
}
pub fn repo_root_from_change_dir(change_dir: &Path) -> Option<&Path> {
let changes = change_dir.parent()?;
let llmanspec = changes.parent()?;
if llmanspec.file_name()?.to_str()? != LLMANSPEC_DIR_NAME {
return None;
}
llmanspec.parent()
}
pub fn skip_from_frontmatter(fm: &ProposalFrontmatter) -> bool {
fm.skip_specs_landing
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sdd::change::git_native::{ChangeGitBinding, run_git, write_binding};
use std::fs;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
run_git(root, &["init"]).unwrap();
run_git(root, &["config", "user.email", "t@t"]).unwrap();
run_git(root, &["config", "user.name", "t"]).unwrap();
run_git(root, &["checkout", "-b", "main"]).unwrap();
fs::create_dir_all(root.join("llmanspec/specs/sample")).unwrap();
fs::write(
root.join("llmanspec/specs/sample/spec.toon"),
"kind: llman.sdd.spec\nname: sample\npurpose: p\nvalid_scope[1]: x\nrequirements[0]{req_id,title,statement}:\nscenarios[0]{req_id,id,given,when,then}:\n",
)
.unwrap();
fs::create_dir_all(root.join("llmanspec/changes/c1")).unwrap();
fs::write(
root.join("llmanspec/changes/c1/proposal.md"),
"---\ndepends_on: []\n---\n\n## Why\nw\n\n## What Changes\n- x\n",
)
.unwrap();
fs::write(root.join("llmanspec/changes/c1/design.md"), "# d\n").unwrap();
fs::write(root.join("llmanspec/changes/c1/tasks.md"), "- [ ] t\n").unwrap();
run_git(root, &["add", "."]).unwrap();
run_git(root, &["commit", "-m", "init"]).unwrap();
tmp
}
#[test]
fn landed_when_specs_commit_on_bound_branch() {
let tmp = init_repo();
let root = tmp.path();
let base = run_git(root, &["rev-parse", "HEAD"]).unwrap();
let base = base.trim();
run_git(root, &["checkout", "-b", "sdd/c1"]).unwrap();
write_binding(
root,
"c1",
&ChangeGitBinding {
branch: "sdd/c1".into(),
base_sha: base.to_string(),
checkpointed: false,
checkpoint_sha: None,
},
)
.unwrap();
fs::write(
root.join("llmanspec/specs/sample/sample.feature"),
"# capability: sample\n# purpose: updated\n# scope: x\n\nFeature: sample\n",
)
.unwrap();
run_git(root, &["add", "llmanspec/specs"]).unwrap();
run_git(root, &["commit", "-m", "specs"]).unwrap();
let st = evaluate_specs_landing(root, &root.join("llmanspec/changes/c1"));
assert!(st.specs_landed, "{st:?}");
assert!(st.ready_to_implement);
assert!(!st.skip_specs_landing);
}
#[test]
fn not_ready_when_full_without_specs_diff() {
let tmp = init_repo();
let root = tmp.path();
let base = run_git(root, &["rev-parse", "HEAD"]).unwrap();
let base = base.trim().to_string();
run_git(root, &["checkout", "-b", "sdd/c1"]).unwrap();
write_binding(
root,
"c1",
&ChangeGitBinding {
branch: "sdd/c1".into(),
base_sha: base,
checkpointed: false,
checkpoint_sha: None,
},
)
.unwrap();
run_git(root, &["add", "llmanspec/changes"]).unwrap();
run_git(root, &["commit", "-m", "bind"]).unwrap();
let st = evaluate_specs_landing(root, &root.join("llmanspec/changes/c1"));
assert!(!st.specs_landed, "{st:?}");
assert!(!st.ready_to_implement);
assert!(st.detail.as_ref().unwrap().contains("llman-sdd-propose"));
}
#[test]
fn skip_flag_makes_ready_without_specs_diff() {
let tmp = init_repo();
let root = tmp.path();
let base = run_git(root, &["rev-parse", "HEAD"]).unwrap();
let base = base.trim().to_string();
run_git(root, &["checkout", "-b", "sdd/c1"]).unwrap();
fs::write(
root.join("llmanspec/changes/c1/proposal.md"),
"---\ndepends_on: []\nbranch: sdd/c1\nbase_sha: {base}\nskip_specs_landing: true\n---\n\n## Why\nw\n\n## What Changes\n- x\n"
.replace("{base}", &base),
)
.unwrap();
run_git(root, &["add", "."]).unwrap();
run_git(root, &["commit", "-m", "skip"]).unwrap();
let st = evaluate_specs_landing(root, &root.join("llmanspec/changes/c1"));
assert!(st.skip_specs_landing);
assert!(st.ready_to_implement);
assert!(!st.specs_landed);
}
}