use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::Deserialize;
use crate::config::Layout;
use crate::error::Result;
use crate::mirror::{self, Format};
use crate::ops;
use crate::router::Router;
use crate::store;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Board {
pub project: String,
#[serde(default = "self_source")]
pub source: String,
pub mirror: PathBuf,
#[serde(default)]
pub inbox: Option<PathBuf>,
#[serde(default)]
pub claims: Option<PathBuf>,
}
fn self_source() -> String {
"self".to_string()
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct Projection {
board: Vec<Board>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct File {
projection: Projection,
}
pub fn boards(root: &Path) -> Result<Vec<Board>> {
let path = root.join("vissue.toml");
if !path.exists() {
return Ok(Vec::new());
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let parsed: File = toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
Ok(parsed.projection.board)
}
#[must_use]
pub fn resolve_source(router: &Router, source: &str) -> Option<Layout> {
if source == "self" {
return Some(router.default_layout().clone());
}
if let Some(named) = router.named_layout(source) {
return Some(named.clone());
}
let expanded = if let Some(rest) = source.strip_prefix("~/") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(rest))?
} else {
PathBuf::from(source)
};
if !expanded.is_dir() {
return None;
}
let layout = crate::config::layout_at(&expanded).ok()?;
if !layout.root().join("vissue.toml").is_file() && !layout.projects_dir().is_dir() {
return None;
}
Some(layout)
}
#[derive(Debug, Default)]
pub struct Outcome {
pub lines: Vec<String>,
pub touched: Vec<PathBuf>,
pub stale: usize,
pub skipped: usize,
}
pub fn project(router: &Router, repo: &Path, check: bool) -> Result<Outcome> {
let mut out = Outcome::default();
let boards = boards(repo)?;
if boards.is_empty() {
out.lines.push(format!(
"no [[projection.board]] in {}; nothing to project",
repo.join("vissue.toml").display()
));
return Ok(out);
}
for board in &boards {
let mirror_path = repo.join(&board.mirror);
let Some(source) = resolve_source(router, &board.source) else {
out.skipped += 1;
out.lines.push(format!(
"{}: source {} is not on this seat; {} stays as projected",
board.project,
board.source,
board.mirror.display()
));
continue;
};
let projects = vec![board.project.clone()];
if check {
if mirror_path.exists() {
let verdict = mirror::check(&source, &mirror_path, &projects)?;
if !verdict.fresh {
out.stale += 1;
}
out.lines
.push(format!("{}: {}", board.project, verdict.report.trim_end()));
} else {
out.stale += 1;
out.lines.push(format!(
"{}: {} does not exist yet",
board.project,
board.mirror.display()
));
}
continue;
}
if let Some(inbox) = &board.inbox {
let inbox_path = repo.join(inbox);
if inbox_path.exists() {
let folded = ops::fold(&source, &inbox_path, &board.project)?;
out.lines
.push(format!("{}: {}", board.project, folded.trim_end()));
out.touched.push(inbox_path);
}
}
if let Some(claims) = &board.claims {
let claims_path = repo.join(claims);
if claims_path.exists() && apply_claims(&source, &claims_path, &mut out.lines)? {
out.touched.push(claims_path);
}
}
let text = mirror::render(&source, &projects, Format::Org, None)?;
if let Some(parent) = mirror_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
store::replace_file_atomically(&mirror_path, &text)?;
out.lines.push(format!(
"{}: wrote {}",
board.project,
board.mirror.display()
));
out.touched.push(mirror_path);
}
Ok(out)
}
fn apply_claims(source: &Layout, path: &Path, lines: &mut Vec<String>) -> Result<bool> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut changed = false;
let mut rewritten = Vec::new();
for line in text.lines() {
let stamped = if let Some(rest) = line.strip_prefix("* TODO claim ") {
match rest.rsplit_once(" as ") {
Some((issue, agent)) => {
let result = ops::claim_as(source, issue.trim(), false, agent.trim())
.map_or_else(|e| format!("FAILED {e}"), |ok| ok.trim().to_string());
lines.push(format!("claim {issue} as {agent}: {result}"));
Some(format!(
"* DONE claim {} as {} :: {result}",
issue.trim(),
agent.trim()
))
}
None => None,
}
} else if let Some(rest) = line.strip_prefix("* TODO release ") {
match rest.rsplit_once(" as ") {
Some((issue, agent)) => {
let result = ops::update(source, issue.trim(), Some("TODO"), None, None, None)
.map_or_else(|e| format!("FAILED {e}"), |ok| ok.report);
lines.push(format!("release {issue} as {agent}: {result}"));
Some(format!(
"* DONE release {} as {} :: {result}",
issue.trim(),
agent.trim()
))
}
None => None,
}
} else if let Some(rest) = line.strip_prefix("* TODO done ") {
match rest.rsplit_once(" as ") {
Some((issue, agent)) => {
let result = ops::update(source, issue.trim(), Some("DONE"), None, None, None)
.map_or_else(|e| format!("FAILED {e}"), |ok| ok.report);
lines.push(format!("done {issue} as {agent}: {result}"));
Some(format!(
"* DONE done {} as {} :: {result}",
issue.trim(),
agent.trim()
))
}
None => None,
}
} else {
None
};
match stamped {
Some(s) => {
changed = true;
rewritten.push(s);
}
None => rewritten.push(line.to_string()),
}
}
if changed {
let mut body = rewritten.join("\n");
body.push('\n');
store::replace_file_atomically(path, &body)?;
}
Ok(changed)
}
#[must_use]
pub fn find_in_mirrors(repo: &Path, id: &str) -> Option<(Board, String)> {
for board in boards(repo).ok()? {
let path = repo.join(&board.mirror);
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
let lines: Vec<&str> = text.lines().collect();
let Some(at) = lines.iter().position(|l| {
let t = l.trim();
t.starts_with(":ID:") && t[4..].trim() == id
}) else {
continue;
};
let start = lines[..at]
.iter()
.rposition(|l| l.starts_with("** "))
.unwrap_or(at);
let end = lines[at..]
.iter()
.position(|l| l.starts_with("** ") || l.starts_with("* "))
.map_or(lines.len(), |n| at + n);
let block: Vec<String> = lines[start..end]
.iter()
.map(|l| l.strip_prefix('*').unwrap_or(l).to_string())
.collect();
return Some((board, block.join("\n").trim_end().to_string() + "\n"));
}
None
}
#[must_use]
pub fn projected_note(board: &Board) -> String {
match &board.inbox {
Some(inbox) => format!(
"read-only projection of {} from {}; write discovered work to {}",
board.project,
board.source,
inbox.display()
),
None => format!(
"read-only projection of {} from {}; it takes no work back",
board.project, board.source
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_board_is_read_from_the_repository_config() {
let dir = std::env::temp_dir().join(format!("vissue-proj-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("vissue.toml"),
"prefix = \"Issues\"\n\n[[projection.board]]\nproject = \"surf\"\nmirror = \"Software/surf/issues-mirror.org\"\n\n[[projection.board]]\nproject = \"ljos\"\nsource = \"vault\"\nmirror = \"Software/ljos/issues-mirror.org\"\ninbox = \"Software/ljos/inbox.org\"\n",
)
.unwrap();
let boards = boards(&dir).unwrap();
assert_eq!(boards.len(), 2);
assert_eq!(boards[1].source, "vault");
assert_eq!(
boards[1].inbox.as_deref(),
Some(Path::new("Software/ljos/inbox.org"))
);
assert_eq!(
boards[0].source, "self",
"a board with no source is this tracker's"
);
fs::create_dir_all(dir.join("Software/surf")).unwrap();
fs::write(
dir.join("Software/surf/issues-mirror.org"),
"#+TITLE: vissue mirror\n\n* surf\n** TODO [#B] Unrelated\n:PROPERTIES:\n:ID: surf-aaaa\n:END:\n",
)
.unwrap();
fs::create_dir_all(dir.join("Software/ljos")).unwrap();
fs::write(
dir.join("Software/ljos/issues-mirror.org"),
"#+TITLE: vissue mirror\n# SYNC: digest=1 generation=1\n\n* ljos\n** TODO [#A] The seat under a herd :task:\n:PROPERTIES:\n:ID: ljos-kcd6\n:END:\n\nBody line.\n** DONE [#C] Another\n:PROPERTIES:\n:ID: ljos-zzzz\n:END:\n",
)
.unwrap();
let (board, text) = find_in_mirrors(&dir, "ljos-kcd6").expect("found in the mirror");
assert_eq!(board.project, "ljos");
assert!(
text.starts_with("* TODO [#A] The seat under a herd"),
"{text}"
);
assert!(text.contains("Body line."), "{text}");
assert!(!text.contains("Another"), "{text}");
assert!(projected_note(&board).contains("Software/ljos/inbox.org"));
assert!(find_in_mirrors(&dir, "ljos-none").is_none());
let _ = fs::remove_dir_all(&dir);
}
}