1use std::fs;
27use std::path::{Path, PathBuf};
28
29use anyhow::Context;
30use serde::Deserialize;
31
32use crate::config::Layout;
33use crate::error::Result;
34use crate::mirror::{self, Format};
35use crate::ops;
36use crate::router::Router;
37use crate::store;
38
39#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
41#[serde(deny_unknown_fields)]
42pub struct Board {
43 pub project: String,
45 #[serde(default = "self_source")]
47 pub source: String,
48 pub mirror: PathBuf,
50 #[serde(default)]
52 pub inbox: Option<PathBuf>,
53 #[serde(default)]
55 pub claims: Option<PathBuf>,
56}
57
58fn self_source() -> String {
59 "self".to_string()
60}
61
62#[derive(Debug, Default, Deserialize)]
63#[serde(default)]
64struct Projection {
65 board: Vec<Board>,
66}
67
68#[derive(Debug, Default, Deserialize)]
69#[serde(default)]
70struct File {
71 projection: Projection,
72}
73
74pub fn boards(root: &Path) -> Result<Vec<Board>> {
80 let path = root.join("vissue.toml");
81 if !path.exists() {
82 return Ok(Vec::new());
83 }
84 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
85 let parsed: File = toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
86 Ok(parsed.projection.board)
87}
88
89#[must_use]
93pub fn resolve_source(router: &Router, source: &str) -> Option<Layout> {
94 if source == "self" {
95 return Some(router.default_layout().clone());
96 }
97 if let Some(named) = router.named_layout(source) {
98 return Some(named.clone());
99 }
100 let expanded = if let Some(rest) = source.strip_prefix("~/") {
103 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(rest))?
104 } else {
105 PathBuf::from(source)
106 };
107 if !expanded.is_dir() {
108 return None;
109 }
110 let layout = crate::config::layout_at(&expanded).ok()?;
111 if !layout.root().join("vissue.toml").is_file() && !layout.projects_dir().is_dir() {
112 return None;
113 }
114 Some(layout)
115}
116
117#[derive(Debug, Default)]
119pub struct Outcome {
120 pub lines: Vec<String>,
122 pub touched: Vec<PathBuf>,
124 pub stale: usize,
126 pub skipped: usize,
128}
129
130pub fn project(router: &Router, repo: &Path, check: bool) -> Result<Outcome> {
138 let mut out = Outcome::default();
139 let boards = boards(repo)?;
140 if boards.is_empty() {
141 out.lines.push(format!(
142 "no [[projection.board]] in {}; nothing to project",
143 repo.join("vissue.toml").display()
144 ));
145 return Ok(out);
146 }
147 for board in &boards {
148 let mirror_path = repo.join(&board.mirror);
149 let Some(source) = resolve_source(router, &board.source) else {
150 out.skipped += 1;
151 out.lines.push(format!(
152 "{}: source {} is not on this seat; {} stays as projected",
153 board.project,
154 board.source,
155 board.mirror.display()
156 ));
157 continue;
158 };
159 let projects = vec![board.project.clone()];
160 if check {
161 if mirror_path.exists() {
162 let verdict = mirror::check(&source, &mirror_path, &projects)?;
163 if !verdict.fresh {
164 out.stale += 1;
165 }
166 out.lines
167 .push(format!("{}: {}", board.project, verdict.report.trim_end()));
168 } else {
169 out.stale += 1;
170 out.lines.push(format!(
171 "{}: {} does not exist yet",
172 board.project,
173 board.mirror.display()
174 ));
175 }
176 continue;
177 }
178 if let Some(inbox) = &board.inbox {
179 let inbox_path = repo.join(inbox);
180 if inbox_path.exists() {
181 let folded = ops::fold(&source, &inbox_path, &board.project)?;
182 out.lines
183 .push(format!("{}: {}", board.project, folded.trim_end()));
184 out.touched.push(inbox_path);
185 }
186 }
187 if let Some(claims) = &board.claims {
188 let claims_path = repo.join(claims);
189 if claims_path.exists() && apply_claims(&source, &claims_path, &mut out.lines)? {
190 out.touched.push(claims_path);
191 }
192 }
193 let text = mirror::render(&source, &projects, Format::Org, None)?;
194 if let Some(parent) = mirror_path.parent() {
195 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
196 }
197 store::replace_file_atomically(&mirror_path, &text)?;
198 out.lines.push(format!(
199 "{}: wrote {}",
200 board.project,
201 board.mirror.display()
202 ));
203 out.touched.push(mirror_path);
204 }
205 Ok(out)
206}
207
208fn apply_claims(source: &Layout, path: &Path, lines: &mut Vec<String>) -> Result<bool> {
211 let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
212 let mut changed = false;
213 let mut rewritten = Vec::new();
214 for line in text.lines() {
215 let stamped = if let Some(rest) = line.strip_prefix("* TODO claim ") {
216 match rest.rsplit_once(" as ") {
217 Some((issue, agent)) => {
218 let result = ops::claim_as(source, issue.trim(), false, agent.trim())
219 .map_or_else(|e| format!("FAILED {e}"), |ok| ok.trim().to_string());
220 lines.push(format!("claim {issue} as {agent}: {result}"));
221 Some(format!(
222 "* DONE claim {} as {} :: {result}",
223 issue.trim(),
224 agent.trim()
225 ))
226 }
227 None => None,
228 }
229 } else if let Some(rest) = line.strip_prefix("* TODO release ") {
230 match rest.rsplit_once(" as ") {
231 Some((issue, agent)) => {
232 let result = ops::update(source, issue.trim(), Some("TODO"), None, None, None)
233 .map_or_else(|e| format!("FAILED {e}"), |ok| ok.report);
234 lines.push(format!("release {issue} as {agent}: {result}"));
235 Some(format!(
236 "* DONE release {} as {} :: {result}",
237 issue.trim(),
238 agent.trim()
239 ))
240 }
241 None => None,
242 }
243 } else if let Some(rest) = line.strip_prefix("* TODO done ") {
244 match rest.rsplit_once(" as ") {
247 Some((issue, agent)) => {
248 let result = ops::update(source, issue.trim(), Some("DONE"), None, None, None)
249 .map_or_else(|e| format!("FAILED {e}"), |ok| ok.report);
250 lines.push(format!("done {issue} as {agent}: {result}"));
251 Some(format!(
252 "* DONE done {} as {} :: {result}",
253 issue.trim(),
254 agent.trim()
255 ))
256 }
257 None => None,
258 }
259 } else {
260 None
261 };
262 match stamped {
263 Some(s) => {
264 changed = true;
265 rewritten.push(s);
266 }
267 None => rewritten.push(line.to_string()),
268 }
269 }
270 if changed {
271 let mut body = rewritten.join("\n");
272 body.push('\n');
273 store::replace_file_atomically(path, &body)?;
274 }
275 Ok(changed)
276}
277
278#[must_use]
282pub fn find_in_mirrors(repo: &Path, id: &str) -> Option<(Board, String)> {
283 for board in boards(repo).ok()? {
284 let path = repo.join(&board.mirror);
285 let Ok(text) = fs::read_to_string(&path) else {
286 continue;
287 };
288 let lines: Vec<&str> = text.lines().collect();
289 let Some(at) = lines.iter().position(|l| {
290 let t = l.trim();
291 t.starts_with(":ID:") && t[4..].trim() == id
292 }) else {
293 continue;
294 };
295 let start = lines[..at]
296 .iter()
297 .rposition(|l| l.starts_with("** "))
298 .unwrap_or(at);
299 let end = lines[at..]
300 .iter()
301 .position(|l| l.starts_with("** ") || l.starts_with("* "))
302 .map_or(lines.len(), |n| at + n);
303 let block: Vec<String> = lines[start..end]
304 .iter()
305 .map(|l| l.strip_prefix('*').unwrap_or(l).to_string())
306 .collect();
307 return Some((board, block.join("\n").trim_end().to_string() + "\n"));
308 }
309 None
310}
311
312#[must_use]
314pub fn projected_note(board: &Board) -> String {
315 match &board.inbox {
316 Some(inbox) => format!(
317 "read-only projection of {} from {}; write discovered work to {}",
318 board.project,
319 board.source,
320 inbox.display()
321 ),
322 None => format!(
323 "read-only projection of {} from {}; it takes no work back",
324 board.project, board.source
325 ),
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn a_board_is_read_from_the_repository_config() {
335 let dir = std::env::temp_dir().join(format!("vissue-proj-{}", std::process::id()));
336 fs::create_dir_all(&dir).unwrap();
337 fs::write(
338 dir.join("vissue.toml"),
339 "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",
340 )
341 .unwrap();
342 let boards = boards(&dir).unwrap();
343 assert_eq!(boards.len(), 2);
344 assert_eq!(boards[1].source, "vault");
345 assert_eq!(
346 boards[1].inbox.as_deref(),
347 Some(Path::new("Software/ljos/inbox.org"))
348 );
349 assert_eq!(
350 boards[0].source, "self",
351 "a board with no source is this tracker's"
352 );
353 fs::create_dir_all(dir.join("Software/surf")).unwrap();
356 fs::write(
357 dir.join("Software/surf/issues-mirror.org"),
358 "#+TITLE: vissue mirror\n\n* surf\n** TODO [#B] Unrelated\n:PROPERTIES:\n:ID: surf-aaaa\n:END:\n",
359 )
360 .unwrap();
361 fs::create_dir_all(dir.join("Software/ljos")).unwrap();
362 fs::write(
363 dir.join("Software/ljos/issues-mirror.org"),
364 "#+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",
365 )
366 .unwrap();
367 let (board, text) = find_in_mirrors(&dir, "ljos-kcd6").expect("found in the mirror");
368 assert_eq!(board.project, "ljos");
369 assert!(
370 text.starts_with("* TODO [#A] The seat under a herd"),
371 "{text}"
372 );
373 assert!(text.contains("Body line."), "{text}");
374 assert!(!text.contains("Another"), "{text}");
375 assert!(projected_note(&board).contains("Software/ljos/inbox.org"));
376 assert!(find_in_mirrors(&dir, "ljos-none").is_none());
377 let _ = fs::remove_dir_all(&dir);
378 }
379}