use anyhow::Result;
use rusqlite::Connection;
use serde_json::{Value, json};
use crate::commands;
use crate::infrastructure::config::Config;
use crate::infrastructure::db;
const RECALL_STEP_TEXT: &str = "Recall prior art before doing anything else";
const RECALL_STEP_INTENT: &str = "Before investigating or editing, decide what prior knowledge bears on this task — the patterns, prior fixes, gotchas, and conventions it might repeat — then call `recall` with a query aimed at exactly that. Record which memories apply (or are deliberately rejected, and why) when you close this step. Do this first so prior art shapes the work rather than being consulted after the fact.";
#[allow(clippy::too_many_arguments)]
pub fn begin_value(
conn: &Connection,
cfg: &Config,
description: &str,
tags: &[String],
files: &[String],
project: Option<&str>,
priority: Option<&str>,
assignment: Option<&str>,
rationale: Option<&str>,
check: Option<&str>,
verify: Option<&str>,
) -> Result<Value> {
if description.trim().is_empty() {
anyhow::bail!("Task description cannot be empty");
}
let mut warnings: Vec<String> = Vec::new();
let created = commands::add::run_value(
conn,
cfg,
&[description.to_string()],
project,
priority,
tags,
None,
&[],
&[],
&[],
&[],
)?;
let id_num = created["id"].as_i64().unwrap_or_default();
let id = id_num.to_string();
let anchor_files: Vec<&str> = files
.iter()
.map(|f| f.trim())
.filter(|f| !f.is_empty())
.collect();
if !anchor_files.is_empty()
&& let Ok(uuid) = created["uuid"]
.as_str()
.unwrap_or_default()
.parse::<uuid::Uuid>()
{
for path in &anchor_files {
db::add_task_file(
conn,
&uuid,
path,
"agent",
Some("declared at begin"),
None,
None,
None,
)?;
}
}
let assignment_text = assignment
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| description.trim());
commands::guide::assignment_value(conn, &id, assignment_text)?;
let rationale_text = rationale.map(str::trim).filter(|s| !s.is_empty());
if let Some(why) = rationale_text {
commands::guide::rationale_value(conn, &id, why)?;
}
let acceptance = match check.map(str::trim).filter(|s| !s.is_empty()) {
Some(text) => {
let c = commands::guide::check_value(
conn,
&id,
text,
None,
Some("acceptance"),
Some("agent"),
verify,
)?;
if let Some(w) = c["warning"].as_str() {
warnings.push(w.to_string());
}
Some(c)
}
None => {
warnings.push(
"no acceptance criterion — define done with \
`sara check <id> \"…\" --kind acceptance --verify \"<cmd>\"`"
.to_string(),
);
None
}
};
let recall_step = commands::guide::check_value(
conn,
&id,
RECALL_STEP_TEXT,
Some(RECALL_STEP_INTENT),
Some("step"),
Some("sara"),
None,
)?;
let next = commands::guide::next_value(conn, &id)?;
Ok(json!({
"task": id_num,
"uuid": created["uuid"],
"project": created["project"],
"description": description.trim(),
"assignment": assignment_text,
"rationale": rationale_text,
"acceptance": acceptance,
"recall_step": recall_step,
"next": next,
"warnings": warnings,
}))
}
#[allow(clippy::too_many_arguments)]
pub fn run(
conn: &Connection,
cfg: &Config,
description: &str,
tags: &[String],
files: &[String],
project: Option<&str>,
priority: Option<&str>,
assignment: Option<&str>,
rationale: Option<&str>,
check: Option<&str>,
verify: Option<&str>,
as_json: bool,
) -> Result<()> {
let v = begin_value(
conn,
cfg,
description,
tags,
files,
project,
priority,
assignment,
rationale,
check,
verify,
)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&v)?);
return Ok(());
}
let task = v["task"].as_i64().unwrap_or_default();
let project = v["project"].as_str().unwrap_or("");
println!(
"Started task {task} in {project}: {}",
v["description"].as_str().unwrap_or("")
);
match v["acceptance"].as_object() {
Some(a) => println!(" acceptance: {}", a["text"].as_str().unwrap_or("")),
None => println!(" acceptance: (none — add one with `sara check`)"),
}
println!(" step 1: {RECALL_STEP_TEXT} — run `sara recall` before you act");
if let Some(next) = v["next"].as_object() {
if next.get("done").and_then(Value::as_bool) == Some(true) {
println!(" next: no steps yet — add them with `sara check <id> \"…\"`");
} else if let Some(text) = next.get("text").and_then(Value::as_str) {
println!(" next: {text}");
} else {
println!(" next: `sara next {task}`");
}
}
for w in v["warnings"].as_array().cloned().unwrap_or_default() {
if let Some(w) = w.as_str() {
eprintln!("warning: {w}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::db;
fn cfg() -> Config {
Config::default()
}
#[test]
fn begin_founds_a_task_with_assignment_and_next_cursor() {
let conn = db::open_in_memory_for_test();
let v = begin_value(
&conn,
&cfg(),
"Fix the failing build",
&["ci".to_string()],
&[],
Some("proj"),
None,
None,
Some("the restore is red on NU1608"),
Some("dotnet build is green"),
Some("dotnet build"),
)
.expect("begin succeeds");
assert!(v["task"].as_i64().unwrap() > 0, "a task id is minted");
assert_eq!(v["assignment"].as_str().unwrap(), "Fix the failing build");
assert_eq!(
v["rationale"].as_str().unwrap(),
"the restore is red on NU1608"
);
assert_eq!(
v["acceptance"]["kind"].as_str().unwrap(),
db::STEP_KIND_ACCEPTANCE
);
assert!(
v.get("recall").is_none() && v.get("finding").is_none(),
"begin no longer auto-recalls: no recall/finding keys, got {v}"
);
assert_eq!(
v["recall_step"]["kind"].as_str().unwrap(),
db::STEP_KIND_STEP,
"a checklist step is seeded"
);
assert_eq!(
v["next"]["text"].as_str().unwrap(),
RECALL_STEP_TEXT,
"the recall step is the next cursor, got {}",
v["next"]
);
}
#[test]
fn missing_acceptance_warns_but_still_founds_the_task() {
let conn = db::open_in_memory_for_test();
let v = begin_value(
&conn,
&cfg(),
"Add a config flag",
&[],
&[],
Some("proj"),
None,
None,
None,
None,
None,
)
.expect("begin succeeds without acceptance");
assert!(v["task"].as_i64().unwrap() > 0);
assert!(
v["acceptance"].is_null(),
"no acceptance criterion recorded"
);
let warnings: Vec<String> = v["warnings"]
.as_array()
.unwrap()
.iter()
.filter_map(|w| w.as_str().map(str::to_string))
.collect();
assert!(
warnings
.iter()
.any(|w| w.contains("no acceptance criterion")),
"an absent definition of done is warned, not blocked: {warnings:?}"
);
}
}