use std::path::{Path, PathBuf};
use chrono::{DateTime, Local, Utc};
use crate::domain::{Task, TaskState};
use crate::store::Store;
pub const FILE_NAME: &str = "task.md";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub name: String,
pub branch: Option<String>,
pub worktree: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Brief {
pub task: Task,
pub targets: Vec<Target>,
pub started_at: Option<DateTime<Utc>>,
}
impl Brief {
pub fn read(store: &Store, task_id: i64) -> crate::store::Result<Self> {
let task = store.get_task(task_id)?;
let targets = store
.list_task_repos(task_id)
.unwrap_or_default()
.into_iter()
.filter_map(|link| {
let repo = store.get_repo(link.repo_id).ok()?;
Some(Target {
name: repo.name,
branch: link.branch,
worktree: link.worktree_path,
})
})
.collect();
let started_at = store
.list_events(task_id)
.unwrap_or_default()
.into_iter()
.find(|event| {
event.kind == "task.transition"
&& event.payload["to"] == TaskState::Running.as_str()
})
.map(|event| event.created_at);
Ok(Self {
task,
targets,
started_at,
})
}
pub fn repo_line(&self) -> String {
self.targets
.iter()
.map(|target| target.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub fn branch(&self) -> Option<&str> {
self.targets
.iter()
.find_map(|target| target.branch.as_deref())
}
pub fn to_markdown(&self) -> String {
let mut out = String::new();
out.push_str(&format!("# {}\n\n", self.task.title));
out.push_str(&format!("marver task {}\n\n", self.task.id));
out.push_str(&format!("- Created: {}\n", stamp(self.task.created_at)));
if let Some(started) = self.started_at {
out.push_str(&format!("- Started: {}\n", stamp(started)));
}
if !self.targets.is_empty() {
out.push_str(&format!("- Repos: {}\n", self.repo_line()));
}
if let Some(branch) = self.branch() {
out.push_str(&format!("- Branch: `{branch}`\n"));
}
out.push_str(&format!(
"- Workspace: `{}`\n",
self.task.workspace_dir.display()
));
out.push_str("\n## Prompt\n\n");
out.push_str(self.task.prompt.trim_end());
out.push('\n');
out
}
}
pub fn write(brief: &Brief, dir: &Path) -> std::io::Result<PathBuf> {
let path = dir.join(FILE_NAME);
std::fs::write(&path, brief.to_markdown())?;
Ok(path)
}
pub fn stamp(at: DateTime<Utc>) -> String {
at.with_timezone(&Local)
.format("%Y-%m-%d %H:%M")
.to_string()
}
pub fn ago(at: DateTime<Utc>, now: DateTime<Utc>) -> String {
let seconds = (now - at).num_seconds().max(0);
let (n, unit) = match seconds {
s if s < 60 => return "just now".into(),
s if s < 3600 => (s / 60, "m"),
s if s < 86_400 => (s / 3600, "h"),
s => (s / 86_400, "d"),
};
format!("{n}{unit} ago")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::Repo;
use crate::git::testing::init_repo;
use crate::store::Transition;
use crate::worktree::WorktreeManager;
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
struct Fixture {
_tmp: TempDir,
repos_dir: PathBuf,
tasks_dir: PathBuf,
store: Store,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let repos_dir = tmp.path().join("repos");
std::fs::create_dir_all(&repos_dir).unwrap();
Self {
tasks_dir: tmp.path().join("tasks"),
repos_dir,
store: Store::open_in_memory().unwrap(),
_tmp: tmp,
}
}
fn repo(&self, name: &str) -> Repo {
let path = self.repos_dir.join(name);
init_repo(&path, "main");
self.store.upsert_repo(&path, name, at(0)).unwrap()
}
fn task(&mut self, title: &str, prompt: &str, repos: &[Repo]) -> Task {
let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
self.store
.create_task(title, prompt, &self.tasks_dir, &ids, at(10))
.unwrap()
}
}
#[test]
fn a_brief_says_what_the_task_was_asked_to_do() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.task("Fix auth", "fix the auth flow\nand the tests", &[repo]);
let brief = Brief::read(&fx.store, task.id).unwrap();
assert_eq!(brief.task.title, "Fix auth");
assert_eq!(brief.repo_line(), "api");
assert_eq!(brief.branch(), None, "not provisioned yet");
assert_eq!(brief.started_at, None, "nor started");
}
#[test]
fn started_is_when_an_agent_first_began_rather_than_the_last_thing_to_happen() {
let mut fx = Fixture::new();
let task = fx.task("Fix auth", "do it", &[]);
fx.store
.transition(task.id, TaskState::Running, Transition::Plain, at(20))
.unwrap();
fx.store
.transition(task.id, TaskState::Paused, Transition::Plain, at(30))
.unwrap();
fx.store
.transition(task.id, TaskState::Running, Transition::Plain, at(40))
.unwrap();
let brief = Brief::read(&fx.store, task.id).unwrap();
assert_eq!(brief.started_at, Some(at(20)));
}
#[test]
fn the_branch_and_worktree_appear_once_it_is_provisioned() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.task("Fix auth", "do it", std::slice::from_ref(&repo));
WorktreeManager::new(&fx.tasks_dir)
.provision(&fx.store, &task)
.unwrap();
let brief = Brief::read(&fx.store, task.id).unwrap();
assert!(brief.branch().unwrap().starts_with("marver/"));
assert!(brief.targets[0].worktree.is_some());
}
#[test]
fn the_markdown_carries_the_prompt_exactly_as_it_was_given() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let prompt = "fix the auth flow\n\n- the tests too\n- and the docs";
let task = fx.task("Fix auth", prompt, &[repo]);
fx.store
.transition(task.id, TaskState::Running, Transition::Plain, at(20))
.unwrap();
let text = Brief::read(&fx.store, task.id).unwrap().to_markdown();
assert!(text.starts_with("# Fix auth\n"), "{text}");
assert!(text.contains(&format!("marver task {}", task.id)));
assert!(text.contains("- Repos: api"));
assert!(text.contains("- Created: "));
assert!(text.contains("- Started: "));
assert!(
text.contains(prompt),
"the prompt must survive verbatim, newlines included: {text}"
);
}
#[test]
fn a_prompt_that_looks_like_markdown_is_not_mangled() {
let mut fx = Fixture::new();
let prompt = "# Heading\n\n```rust\nfn main() {}\n```";
let task = fx.task("Odd", prompt, &[]);
let text = Brief::read(&fx.store, task.id).unwrap().to_markdown();
assert!(text.contains("```rust\nfn main() {}\n```"), "{text}");
}
#[test]
fn writing_puts_it_where_the_work_is() {
let mut fx = Fixture::new();
let task = fx.task("Fix auth", "do it", &[]);
let dir = fx.tasks_dir.join("4");
std::fs::create_dir_all(&dir).unwrap();
let brief = Brief::read(&fx.store, task.id).unwrap();
let path = write(&brief, &dir).unwrap();
assert_eq!(path, dir.join("task.md"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), brief.to_markdown());
}
#[test]
fn how_long_ago_is_said_in_the_largest_unit_that_is_true() {
assert_eq!(ago(at(100), at(130)), "just now");
assert_eq!(ago(at(0), at(600)), "10m ago");
assert_eq!(ago(at(0), at(7200)), "2h ago");
assert_eq!(ago(at(0), at(200_000)), "2d ago");
assert_eq!(ago(at(500), at(0)), "just now", "clocks going backwards");
}
}