marver 0.0.28

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! What a task is, gathered in one place — and written down beside its work.

use std::path::{Path, PathBuf};

use chrono::{DateTime, Local, Utc};

use crate::domain::{Task, TaskState};
use crate::store::Store;

/// The file dropped in a task's workspace directory.
pub const FILE_NAME: &str = "task.md";

/// One repo, as the brief describes it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
    pub name: String,
    /// Set once the task has been provisioned.
    pub branch: Option<String>,
    pub worktree: Option<PathBuf>,
}

/// Everything worth knowing about a task in one screen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Brief {
    pub task: Task,
    pub targets: Vec<Target>,
    /// When an agent first began work, as opposed to when the task was
    /// described. Read from the event log rather than stored: `updated_at`
    /// moves with every hook, and a task that was paused and resumed started
    /// once.
    pub started_at: Option<DateTime<Utc>>,
}

impl Brief {
    /// Gather a task's brief from the store.
    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();

        // The first move into `running`.
        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,
        })
    }

    /// The repos this task targets, as one line.
    pub fn repo_line(&self) -> String {
        self.targets
            .iter()
            .map(|target| target.name.as_str())
            .collect::<Vec<_>>()
            .join(", ")
    }

    /// The branch its worktrees are on, if it has one.
    pub fn branch(&self) -> Option<&str> {
        self.targets
            .iter()
            .find_map(|target| target.branch.as_deref())
    }

    /// The whole brief as Markdown, for [`FILE_NAME`].
    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");
        // Verbatim, including its newlines.
        out.push_str(self.task.prompt.trim_end());
        out.push('\n');
        out
    }
}

/// Write `task.md` into a task's workspace directory.
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)
}

/// A timestamp as the person reading it experiences it.
pub fn stamp(at: DateTime<Utc>) -> String {
    at.with_timezone(&Local)
        .format("%Y-%m-%d %H:%M")
        .to_string()
}

/// How long ago, in the largest unit that is still true.
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() {
        // A task that was paused and resumed started once.
        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() {
        // Prompts routinely contain headings, lists, and fenced code.
        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");
    }
}