pub mod doc_tree;
pub mod dolt;
pub mod mysql;
mod renumber;
pub mod server;
use std::path::{Component, Path, PathBuf};
use crate::mission::model::Mission;
use super::status::LedgerStatus;
#[derive(Debug, Clone)]
pub struct RefreshOutcome {
pub path: PathBuf,
pub remote_label: String,
pub remote_head: String,
pub pending: Vec<String>,
#[allow(dead_code)]
pub unpublished: bool,
pub pending_note: Option<String>,
pub unpublished_note: Option<String>,
pub replaced_note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushReport {
pub committed: bool,
pub head: String,
pub already_on_remote: bool,
pub renumbered: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum BindingKind {
Cloud,
Worktree,
}
impl BindingKind {
pub fn as_str(&self) -> &'static str {
match self {
BindingKind::Cloud => "cloud",
BindingKind::Worktree => "worktree",
}
}
pub fn parse(text: &str) -> Option<BindingKind> {
match text {
"cloud" => Some(BindingKind::Cloud),
"worktree" => Some(BindingKind::Worktree),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindingRow {
pub actor: String,
pub kind: BindingKind,
pub thread_id: String,
pub registered_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorKind {
Human,
Robot,
}
impl ActorKind {
pub fn as_str(&self) -> &'static str {
match self {
ActorKind::Human => "human",
ActorKind::Robot => "robot",
}
}
pub fn parse(text: &str) -> Option<ActorKind> {
match text {
"human" => Some(ActorKind::Human),
"robot" => Some(ActorKind::Robot),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActorRow {
pub name: String,
pub kind: ActorKind,
pub registered_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceRow {
pub instance: String,
pub kind: BindingKind,
pub actor_name: String,
pub registered_at: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ThreadInfo {
pub mission_id: Option<String>,
pub briefing_path: Option<String>,
}
pub trait LedgerStore {
fn refresh(&self) -> Result<RefreshOutcome, String>;
fn refresh_for_fetch(&self) -> Result<RefreshOutcome, String> {
self.refresh()
}
fn refresh_keeping_local(&self) -> Result<RefreshOutcome, String> {
self.refresh()
}
fn has_local_changes(&self) -> Result<bool, String> {
Ok(false)
}
fn status(&self) -> Result<LedgerStatus, String>;
fn status_local(&self) -> Result<LedgerStatus, String>;
fn discard(&self) -> Result<(), String>;
fn path(&self) -> Result<PathBuf, String>;
fn exists_locally(&self) -> Result<bool, String>;
fn commit_and_push(
&self,
message: &str,
notes: &mut dyn FnMut(&str),
) -> Result<PushReport, String>;
fn trust_last_fetch(&self) {}
fn distrust_last_fetch(&self) {}
fn head_id(&self) -> Result<String, String>;
fn doc_get(&self, path: &str) -> Result<Option<String>, String>;
fn doc_put(&self, path: &str, body: &str) -> Result<(), String>;
fn doc_list(&self, prefix: &str) -> Result<Vec<String>, String>;
fn doc_remove(&self, path: &str) -> Result<bool, String>;
fn doc_exists(&self, path: &str) -> Result<bool, String>;
fn doc_path(&self, path: &str) -> Result<Option<PathBuf>, String>;
fn thread_exists(&self, id: &str) -> Result<bool, String>;
fn thread_create(
&self,
id: &str,
mission_id: Option<&str>,
briefing_path: Option<&str>,
) -> Result<(), String>;
fn thread_set_mission(
&self,
id: &str,
mission_id: Option<&str>,
briefing_path: Option<&str>,
) -> Result<(), String>;
fn thread_delete(&self, id: &str) -> Result<(), String>;
fn thread_ids(&self) -> Result<Vec<String>, String>;
fn thread_info(&self, id: &str) -> Result<ThreadInfo, String>;
fn continuation_append(&self, id: &str, line: &str) -> Result<(), String>;
fn continuation_append_deferred(&self, id: &str, line: &str) -> Result<(), String> {
self.continuation_append(id, line)
}
fn continuation_list(&self, id: &str) -> Result<Vec<String>, String>;
fn continuation_lists(&self, ids: &[String]) -> Result<Vec<Vec<String>>, String> {
ids.iter().map(|id| self.continuation_list(id)).collect()
}
fn bindings_list(&self) -> Result<Vec<BindingRow>, String>;
fn binding_set(&self, row: &BindingRow) -> Result<(), String>;
fn actors_list(&self) -> Result<Vec<ActorRow>, String>;
fn actor_add(&self, row: &ActorRow) -> Result<(), String>;
fn instances_list(&self) -> Result<Vec<InstanceRow>, String>;
fn instance_set(&self, row: &InstanceRow) -> Result<(), String>;
fn binding_remove(&self, actor: &str) -> Result<(), String>;
fn bindings_remove(&self, actors: &[String]) -> Result<(), String>;
fn binding_remove_thread(&self, thread_id: &str) -> Result<(), String>;
fn events_append(&self, lines: &str) -> Result<(), String>;
fn events_list(&self) -> Result<Option<Vec<String>>, String>;
fn watermark_get(&self) -> Result<u64, String>;
fn watermark_set(&self, processed_through: u64, updated_at: &str) -> Result<(), String>;
fn missions_load(&self) -> Result<Vec<Mission>, String>;
fn mission_save(&self, mission: &Mission) -> Result<(), String>;
fn missions_save(&self, missions: &[Mission]) -> Result<(), String>;
fn mission_rename(&self, from: &str, to: &str) -> Result<(), String>;
fn describe_thread(&self, id: &str) -> String;
fn describe_threads(&self) -> String;
fn pending_inspect_hint(&self, path: &Path, remote_head: &str) -> String;
}
pub fn open(dir: &Path) -> Result<Box<dyn LedgerStore>, String> {
crate::config::refuse_files_backend_from_env()?;
dolt::DoltStore::open(dir).map(|store| Box::new(store) as Box<dyn LedgerStore>)
}
fn relative_parts(path: &str) -> Result<Vec<String>, String> {
let p = Path::new(path);
let mut parts = Vec::new();
for component in p.components() {
match component {
Component::Normal(name) => parts.push(name.to_string_lossy().into_owned()),
Component::CurDir => {}
_ => return Err(not_relative(path)),
}
}
if path.is_empty() || parts.is_empty() {
return Err(not_relative(path));
}
Ok(parts)
}
pub fn check_docs_path(path: &str) -> Result<String, String> {
let parts = relative_parts(path)?;
if parts.iter().any(|part| part == ".git") {
return Err(format!(
"error: '{}' is not a document: .git is managed by git",
path
));
}
Ok(parts.join("/"))
}
fn not_relative(path: &str) -> String {
format!(
"error: document path '{}' must be relative to the ledger root, with no '..'",
path
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn docs_paths_apply_only_the_git_rules() {
for (given, normal) in [
("index.md", "index.md"),
("./missions/M-X.md", "missions/M-X.md"),
("threads/1234abcd/index.md", "threads/1234abcd/index.md"),
(
"external-events/queue.ndjson",
"external-events/queue.ndjson",
),
(".tsk-ledger.toml", ".tsk-ledger.toml"),
] {
assert_eq!(check_docs_path(given).unwrap(), normal, "{}", given);
}
for bad in [
"",
".",
"/etc/passwd",
"../x.md",
"a/../../x.md",
".git",
"a/.git/config",
] {
assert!(check_docs_path(bad).is_err(), "{}", bad);
}
}
}