use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use speccoder_core::{
ApprovalsReport, CommandData, CommandResult, CommandStatus, DecidedCall, PendingCall,
};
use crate::tools::policy::{Grant, Rejection};
pub const FILE: &str = "approvals.json";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskDecisions {
#[serde(default)]
pub approved: Vec<Grant>,
#[serde(default)]
pub rejected: Vec<Rejection>,
#[serde(default)]
pub nudges: Vec<String>,
#[serde(default)]
pub decided: Vec<Decided>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Who {
pub name: String,
pub via: String,
#[serde(default = "asserted")]
pub basis: String,
}
fn asserted() -> String {
"asserted".to_owned()
}
impl Who {
#[must_use]
pub fn local(via: &str) -> Self {
Self::local_in(Path::new("."), via)
}
#[must_use]
pub fn local_in(dir: &Path, via: &str) -> Self {
Self {
name: who_in(dir),
via: via.to_owned(),
basis: if via.starts_with("editor") {
"connected"
} else {
"asserted"
}
.to_owned(),
}
}
#[must_use]
pub fn describe(&self) -> String {
format!("{} ({})", self.name, self.via)
}
#[must_use]
pub fn operator(&self) -> Option<agentplane::core::Operator> {
agentplane::core::Operator::from_parts(&self.name, &self.basis).ok()
}
#[must_use]
pub fn view(&self) -> speccoder_core::OperatorView {
speccoder_core::OperatorView {
actor: self.name.clone(),
basis: self.basis.clone(),
}
}
}
#[must_use]
pub fn who() -> String {
who_in(Path::new("."))
}
#[must_use]
pub fn who_in(dir: &Path) -> String {
crate::git::Git::at(dir)
.line(&["config", "user.name"])
.ok()
.filter(|n| !n.trim().is_empty())
.or_else(|| {
["USER", "USERNAME", "LOGNAME"]
.iter()
.find_map(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty()))
})
.unwrap_or_else(|| "unknown".to_owned())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decided {
pub verb: String,
pub grant: Grant,
#[serde(default)]
pub reason: Option<String>,
#[serde(default = "task_scope")]
pub scope: String,
#[serde(default)]
pub note: Option<String>,
pub by: Who,
pub at: u64,
}
fn task_scope() -> String {
"task".to_owned()
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decisions {
#[serde(default)]
pub tasks: BTreeMap<String, TaskDecisions>,
}
impl Decisions {
pub fn load(dir: &Path) -> Result<Self, String> {
match std::fs::read_to_string(dir.join(FILE)) {
Ok(text) => serde_json::from_str(&text)
.map_err(|e| format!("{}: {e}", dir.join(FILE).display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(format!("{}: {e}", dir.join(FILE).display())),
}
}
pub fn save(&self, dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
let tmp = dir.join(format!("{FILE}.tmp"));
std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, dir.join(FILE)).map_err(|e| format!("{}: {e}", dir.display()))
}
#[must_use]
pub fn for_task(&self, feature: &str, task: &str) -> TaskDecisions {
self.tasks
.get(&format!("{feature}/{task}"))
.cloned()
.unwrap_or_default()
}
pub fn approve(&mut self, feature: &str, task: &str, grant: Grant, by: &Who) {
let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
d.rejected.retain(|r| r.grant != grant);
if !d.approved.contains(&grant) {
d.approved.push(grant.clone());
}
d.decided.push(Decided {
verb: "approve".to_owned(),
grant,
reason: None,
scope: task_scope(),
note: None,
by: by.clone(),
at: now(),
});
}
pub fn note(&mut self, feature: &str, task: &str, decided: Decided) {
self.tasks
.entry(format!("{feature}/{task}"))
.or_default()
.decided
.push(decided);
}
pub fn reject(&mut self, feature: &str, task: &str, grant: Grant, reason: &str, by: &Who) {
let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
d.approved.retain(|g| *g != grant);
d.rejected.retain(|r| r.grant != grant);
d.rejected.push(Rejection {
grant: grant.clone(),
reason: reason.to_owned(),
});
d.decided.push(Decided {
verb: "reject".to_owned(),
grant,
reason: Some(reason.to_owned()),
scope: task_scope(),
note: None,
by: by.clone(),
at: now(),
});
}
#[must_use]
pub fn decided(&self, feature: &str, task: &str, grant: &Grant) -> bool {
let d = self.for_task(feature, task);
d.approved.contains(grant) || d.rejected.iter().any(|r| &r.grant == grant)
}
}
pub mod present;
pub mod tasks;
pub use present::{PresentContext, Presentation, present};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub task: String,
#[serde(default)]
pub task_title: String,
#[serde(default)]
pub turn: u32,
pub tool: String,
pub arguments: serde_json::Value,
#[serde(default)]
pub kind: String,
pub reason: String,
pub covers: String,
pub risk: String,
#[serde(default)]
pub claim: Option<String>,
#[serde(default)]
pub options: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Once,
Task,
Run,
}
impl Scope {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Once => "once",
Self::Task => "task",
Self::Run => "run",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decision {
Approve,
Reject,
Stop,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Answer {
pub decision: Decision,
pub scope: Scope,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub by: Option<Who>,
}
impl Answer {
#[must_use]
pub fn approve(scope: Scope, note: Option<String>, by: Option<Who>) -> Self {
Self {
decision: Decision::Approve,
scope,
note: note.filter(|n| !n.trim().is_empty()),
reason: None,
by,
}
}
#[must_use]
pub fn reject(reason: impl Into<String>, by: Option<Who>) -> Self {
Self {
decision: Decision::Reject,
scope: Scope::Once,
note: None,
reason: Some(reason.into()),
by,
}
}
#[must_use]
pub fn stop(by: Option<Who>) -> Self {
Self {
decision: Decision::Stop,
scope: Scope::Once,
note: None,
reason: None,
by,
}
}
}
pub trait Approver: Send + Sync + std::fmt::Debug {
fn ask(&self, p: &Presentation) -> Answer;
fn answer(&self, question: &str) -> Option<String> {
let _ = question;
None
}
fn via(&self) -> String {
"terminal".to_owned()
}
}
#[must_use]
pub fn kind_of(tool: &str, arguments: &serde_json::Value) -> &'static str {
match tool {
"write_file" => "write",
"edit_file" => "edit",
"run_command" if arguments["network"] == true => "network",
"run_command" => "run",
t if t.starts_with(crate::mcp::PREFIX) => "mcp",
_ => "edit",
}
}
#[must_use]
pub fn options(risk: &str, subject_complete: bool) -> Vec<String> {
let mut o = vec!["once"];
if subject_complete {
o.push("task");
if risk != "high" {
o.push("run");
}
}
o.extend(["reject", "stop"]);
o.into_iter().map(str::to_owned).collect()
}
#[must_use]
pub fn claim(text: &str) -> Option<String> {
let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
let line = present::escape(&line);
if line.is_empty() {
return None;
}
Some(if line.chars().count() > 200 {
format!("{}…", line.chars().take(199).collect::<String>())
} else {
line
})
}
#[must_use]
pub fn risk(tool: &str, reason: &str) -> &'static str {
let r = reason.to_lowercase();
if tool == "run_command" && (r.contains("arbitrary") || r.contains("network")) {
"high"
} else if tool.starts_with(crate::mcp::PREFIX)
|| r.contains("manifest")
|| r.contains("frozen")
|| r.contains("repository state")
|| r.contains("recursive")
{
"medium"
} else {
"low"
}
}
fn locate(
command: &str,
path: &Path,
feature: Option<&str>,
state: &Path,
) -> Result<(String, std::path::PathBuf, std::path::PathBuf), Box<CommandResult>> {
let inspected = crate::inspect(path, feature);
let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
return Err(Box::new(CommandResult::carried(command, &inspected)));
};
let Some(f) = snapshot.feature.as_ref() else {
return Err(Box::new(CommandResult::new(
command,
CommandStatus::SelectionNeeded,
inspected.message(),
)));
};
let root = std::path::PathBuf::from(&snapshot.root);
Ok((
f.feature.name.clone(),
crate::worktree::project_state_dir(state, &root),
root,
))
}
fn pending(
root: &Path,
feature: &str,
open: &[String],
decisions: &Decisions,
) -> Vec<(PendingCall, Grant)> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for saved in crate::attempts::list(root, feature) {
if !open.contains(&saved.task) || !seen.insert(saved.task.clone()) {
continue; }
let Some(p) = saved.pending.filter(|_| saved.outcome == "needs_approval") else {
continue;
};
let tool = p["tool"].as_str().unwrap_or_default().to_owned();
let Some(grant) = Grant::of(&tool, &p["arguments"]) else {
continue;
};
if decisions.decided(feature, &saved.task, &grant) {
continue;
}
let presentation = view(root, feature, &saved.task, &saved.reference, &tool, &p);
out.push((
PendingCall {
task: saved.task.clone(),
covers: grant.describe(),
tool,
arguments: p["arguments"].clone(),
reason: p["reason"].as_str().unwrap_or_default().to_owned(),
reference: saved.reference.clone(),
presentation: Some(presentation),
source: "attempt".to_owned(),
kind: "approval".to_owned(),
..PendingCall::default()
},
grant,
));
}
out
}
fn view(
root: &Path,
feature: &str,
task: &str,
reference: &str,
tool: &str,
pending: &serde_json::Value,
) -> speccoder_core::ApprovalView {
let arguments = pending["arguments"].clone();
let reason = pending["reason"].as_str().unwrap_or_default().to_owned();
let grant = Grant::of(tool, &arguments);
let risk = risk(tool, &reason);
let request = Request {
task: format!("{feature}/{task}"),
task_title: String::new(),
turn: pending["turn"]
.as_u64()
.and_then(|t| u32::try_from(t).ok())
.unwrap_or(0),
tool: tool.to_owned(),
kind: kind_of(tool, &arguments).to_owned(),
covers: grant.as_ref().map(|g| g.describe()).unwrap_or_default(),
risk: risk.to_owned(),
claim: pending["claim"].as_str().map(str::to_owned),
options: options(risk, grant.is_some()),
arguments,
reason,
};
let path = request.arguments["path"].as_str().unwrap_or_default();
let file_text = (!path.is_empty())
.then(|| {
crate::git::Git::at(root)
.run(&["show", &format!("{reference}:./{path}")])
.ok()
.map(|b| String::from_utf8_lossy(&b).into_owned())
})
.flatten();
presented(root, &request, task, file_text)
}
fn presented(
root: &Path,
request: &Request,
task: &str,
file_text: Option<String>,
) -> speccoder_core::ApprovalView {
let config = crate::verify::find_config(root)
.and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
.unwrap_or_default();
let sandbox = crate::sandbox::parse(&config).ok();
let p = present(
request,
&PresentContext {
root: None,
sandbox: sandbox
.as_ref()
.filter(|c| c.mode != crate::sandbox::Mode::Off)
.map(|_| {
if cfg!(target_os = "macos") {
"Seatbelt".to_owned()
} else {
"Landlock+seccomp".to_owned()
}
}),
network: match sandbox.map(|c| c.network) {
Some(crate::sandbox::Network::Loopback) => "loopback",
Some(crate::sandbox::Network::On) => "unrestricted",
_ => "none",
}
.to_owned(),
file_text,
},
);
speccoder_core::ApprovalView {
kind: request.kind.clone(),
title: p.title,
subject: p.subject,
body_lines: p.body,
sandbox: p.sandbox,
claim: request.claim.clone(),
risk: request.risk.clone(),
scope: format!("for the rest of {task}: {}", p.scope),
}
}
fn decided(feature: &str, decisions: &Decisions) -> Vec<DecidedCall> {
let prefix = format!("{feature}/");
let mut out = Vec::new();
for (key, d) in &decisions.tasks {
let Some(task) = key.strip_prefix(&prefix) else {
continue;
};
let last = |g: &Grant| d.decided.iter().rev().find(|x| x.grant == *g);
out.extend(d.approved.iter().map(|g| DecidedCall {
task: task.to_owned(),
decision: "approved".to_owned(),
covers: g.describe(),
reason: None,
by: last(g).map(|x| x.by.describe()),
scope: Some("task".to_owned()),
note: last(g).and_then(|x| x.note.clone()),
operator: last(g).map(|x| x.by.view()),
via: last(g).map(|x| x.by.via.clone()),
..DecidedCall::default()
}));
out.extend(d.rejected.iter().map(|r| DecidedCall {
task: task.to_owned(),
decision: "rejected".to_owned(),
covers: r.grant.describe(),
reason: Some(r.reason.clone()),
by: last(&r.grant).map(|x| x.by.describe()),
scope: Some("task".to_owned()),
note: None,
operator: last(&r.grant).map(|x| x.by.view()),
via: last(&r.grant).map(|x| x.by.via.clone()),
..DecidedCall::default()
}));
out.extend(
d.decided
.iter()
.filter(|x| x.verb == "approve" && x.scope == "run")
.map(|x| DecidedCall {
task: task.to_owned(),
decision: "approved".to_owned(),
covers: x.grant.describe(),
reason: None,
by: Some(x.by.describe()),
scope: Some("run".to_owned()),
note: x.note.clone(),
operator: Some(x.by.view()),
via: Some(x.by.via.clone()),
..DecidedCall::default()
}),
);
}
out
}
fn waiting_tasks(
root: &Path,
dir: &Path,
feature: &str,
) -> Vec<(PendingCall, agentplane::core::Task)> {
let Ok(rt) = crate::tokio_runtime() else {
return Vec::new();
};
let Ok(plane) = crate::substrate::Plane::open_for_decisions(dir) else {
return Vec::new();
};
let _ = rt.block_on(plane.expire_due(agentplane::core::Timestamp::now_utc()));
let open = rt.block_on(plane.open_tasks()).unwrap_or_default();
let prefix = format!("{feature}/");
open.into_iter()
.filter(|t| t.kind == tasks::APPROVAL)
.filter_map(|t| {
let request = tasks::request_of(&t.justification.proposed_action)?;
let task = request.task.strip_prefix(&prefix)?.to_owned();
let view = presented(root, &request, &task, None);
Some((
PendingCall {
task,
covers: request.covers.clone(),
tool: request.tool.clone(),
arguments: request.arguments.clone(),
reason: request.reason.clone(),
reference: String::new(),
presentation: Some(view),
call: t.id.to_hex(),
source: "task".to_owned(),
kind: tasks::APPROVAL.to_owned(),
question: None,
waiting_since: Some(t.created_at.unix_timestamp().max(0).unsigned_abs()),
deadline: t.due_at.map(|d| d.unix_timestamp().max(0).unsigned_abs()),
},
t,
))
})
.collect()
}
pub fn expire_due(dir: &Path, now: agentplane::core::Timestamp) -> Result<(), String> {
let rt = crate::tokio_runtime()?;
let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
rt.block_on(plane.expire_due(now)).map_err(|e| e.0)
}
#[must_use]
pub fn waiting_elsewhere(
root: &Path,
dir: &Path,
feature: &str,
task: &str,
) -> Option<(String, String)> {
waiting_tasks(root, dir, feature)
.into_iter()
.map(|(c, _)| c)
.find(|c| c.task != task && c.source == "task")
.map(|c| (c.task, c.covers))
}
pub fn withdraw_waiting(
root: &Path,
dir: &Path,
feature: &str,
reason: &str,
) -> Result<usize, String> {
let rows = waiting_tasks(root, dir, feature);
if rows.is_empty() {
return Ok(0);
}
let rt = crate::tokio_runtime()?;
let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
let by = Who::local_in(root, "cli")
.operator()
.ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
for (_, task) in &rows {
rt.block_on(plane.withdraw(task, &by, reason))
.map_err(|e| e.0)?;
}
Ok(rows.len())
}
fn decide_waiting(
dir: &Path,
task: &agentplane::core::Task,
answer: &Answer,
by: &Who,
) -> Result<(), String> {
let rt = crate::tokio_runtime()?;
let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
let decision = tasks::decision_for(answer, by, None)
.ok_or_else(|| format!("`{}` is not a name a decision can carry", by.name))?;
rt.block_on(plane.decide(task.id, &decision))
.map(|_| ())
.map_err(|e| e.0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb<'a> {
List,
Approve(&'a str),
ApproveNoted(&'a str, &'a str),
Reject(&'a str, &'a str),
Decide(Choice<'a>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Choice<'a> {
pub task: Option<&'a str>,
pub call: Option<&'a str>,
pub all: bool,
pub approve: bool,
pub scope: Scope,
pub note: Option<&'a str>,
pub reason: Option<&'a str>,
}
impl<'a> Choice<'a> {
#[must_use]
pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
Self {
task: Some(task),
call: None,
all: false,
approve: true,
scope: Scope::Task,
note,
reason: None,
}
}
#[must_use]
pub fn reject(task: &'a str, reason: &'a str) -> Self {
Self {
task: Some(task),
call: None,
all: false,
approve: false,
scope: Scope::Task,
note: None,
reason: Some(reason),
}
}
}
pub fn decide_with(
path: &Path,
feature: Option<&str>,
verb: Verb<'_>,
state: &Path,
) -> CommandResult {
let command = match verb {
Verb::List => "approvals",
Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
Verb::Reject(..) => "reject",
Verb::Decide(c) if c.approve => "approve",
Verb::Decide(_) => "reject",
};
let (feature, dir, root) = match locate(command, path, feature, state) {
Ok(x) => x,
Err(r) => return *r,
};
let mut decisions = match Decisions::load(&dir) {
Ok(d) => d,
Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
};
let status = crate::status::report(&root, Some(&feature));
let open: Vec<String> = match status.data() {
Some(CommandData::Report(r)) => r
.tasks
.iter()
.filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
.map(|t| t.id.clone())
.collect(),
_ => Vec::new(),
};
let tasks_waiting = waiting_tasks(&root, &dir, &feature);
let waiting = pending(&root, &feature, &open, &decisions);
let choice = match verb {
Verb::List => None,
Verb::Approve(t) => Some(Choice::approve(t, None)),
Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
Verb::Reject(t, r) => Some(Choice::reject(t, r)),
Verb::Decide(c) => Some(c),
};
if let Some(c) = choice {
let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
.iter()
.filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
.filter(|(p, _)| {
c.call
.is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
})
.collect();
if matching.len() > 1 && !c.all {
let list: Vec<String> = matching
.iter()
.map(|(p, _)| {
format!(
"{} ({}, {})",
&p.call[..12.min(p.call.len())],
p.task,
p.covers
)
})
.collect();
return CommandResult::new(
command,
CommandStatus::UsageError,
&format!(
"{} calls are waiting; name one with --call, or decide them all with --all: {}",
matching.len(),
list.join(", ")
),
);
}
if !matching.is_empty() {
let by = Who::local_in(&root, "cli");
let mut lines = Vec::new();
for (call, row) in &matching {
let answer = if c.approve {
Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
} else {
Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
};
if let Err(e) = decide_waiting(&dir, row, &answer, &by) {
return CommandResult::new(command, CommandStatus::Error, &e);
}
lines.push(if c.approve {
format!(
"approved: {} for {} ({}) — by {} ({})",
call.covers,
call.task,
c.scope.name(),
by.name,
by.basis
)
} else {
format!(
"rejected: {} for {} — by {} ({}); the model will be told: {}",
call.covers,
call.task,
by.name,
by.basis,
c.reason.unwrap_or("no reason given")
)
});
}
let report = ApprovalsReport {
decided: decided(&feature, &decisions),
pending: waiting_tasks(&root, &dir, &feature)
.into_iter()
.map(|(c, _)| c)
.chain(
pending(&root, &feature, &open, &decisions)
.into_iter()
.map(|(c, _)| c),
)
.collect(),
feature,
};
return CommandResult::new(
command,
CommandStatus::Ok,
&format!(
"{}; `speccoder run` continues each attempt where it stopped",
lines.join("\n")
),
)
.with_data(CommandData::Approvals(Box::new(report)));
}
if c.all || c.call.is_some() || c.task.is_none() {
return CommandResult::new(
command,
CommandStatus::NotFound,
"no call matching that awaits a decision (see `speccoder report --approvals`)",
);
}
}
let verb = match verb {
Verb::Decide(c) => match (c.task, c.approve, c.note) {
(Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
(Some(t), true, None) => Verb::Approve(t),
(Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
(None, ..) => Verb::List,
},
v => v,
};
let message = match verb {
Verb::List | Verb::Decide(_) => {
let n = waiting.len() + tasks_waiting.len();
if n == 0 {
"nothing awaits a decision".to_owned()
} else {
format!("{n} call(s) await a decision")
}
}
Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
return CommandResult::new(
command,
CommandStatus::NotFound,
&format!(
"{task} has no call awaiting a decision (see `speccoder report --approvals`)"
),
);
};
let what = match verb {
Verb::Reject(_, reason) => {
decisions.reject(
&feature,
task,
grant.clone(),
reason,
&Who::local_in(&root, "cli"),
);
format!(
"rejected: {} for {task} — the model will be told: {reason}",
call.covers
)
}
_ => {
decisions.approve(&feature, task, grant.clone(), &Who::local_in(&root, "cli"));
if let Verb::ApproveNoted(_, note) = verb
&& !note.trim().is_empty()
{
let t = decisions
.tasks
.entry(format!("{feature}/{task}"))
.or_default();
if let Some(last) = t.decided.last_mut() {
last.note = Some(note.trim().to_owned());
}
t.nudges.push(format!(
"A person approved {}: {}",
call.covers,
note.trim()
));
}
format!("approved: {} for {task}", call.covers)
}
};
if let Err(e) = decisions.save(&dir) {
return CommandResult::new(command, CommandStatus::Error, &e);
}
format!("{what}; run the task again to continue (speccoder run --task {task})")
}
};
let report = ApprovalsReport {
decided: decided(&feature, &decisions),
pending: tasks_waiting
.into_iter()
.map(|(c, _)| c)
.chain(
pending(&root, &feature, &open, &decisions)
.into_iter()
.map(|(c, _)| c),
)
.collect(),
feature,
};
CommandResult::new(command, CommandStatus::Ok, &message)
.with_data(CommandData::Approvals(Box::new(report)))
}
#[must_use]
pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>) -> CommandResult {
match crate::worktree::state_dir() {
Some(state) => decide_with(path, feature, verb, &state),
None => CommandResult::new(
"approvals",
CommandStatus::ConfigError,
"no state directory (set SPECCODER_STATE_DIR or HOME)",
)
.with_code("NO-STATE-DIR"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decisions_round_trip_and_replace_each_other() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let g = Grant::Write {
path: "Cargo.toml".to_owned(),
};
let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
let by = Who::local("cli");
d.reject("f", "T1", g.clone(), "no new dependencies", &by);
d.approve("f", "T1", g.clone(), &by);
assert_eq!(d.tasks["f/T1"].decided.len(), 2);
assert_eq!(d.tasks["f/T1"].decided[1].by, by);
d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(back.for_task("f", "T1").approved, std::slice::from_ref(&g));
assert!(back.for_task("f", "T1").rejected.is_empty());
assert!(back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
}
}