1use std::collections::{BTreeMap, BTreeSet};
9use std::io::ErrorKind;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, bail};
13use semora_proto::{DocDetail, DocKind, DocSummary};
14use serde::Serialize;
15
16use crate::args::PullArgs;
17use crate::checkout::{self, Local, Record};
18use crate::client::Client;
19use crate::project_link::{self, Link, shown};
20use crate::{Ctx, exit, output};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum State {
26 Clean,
28 Modified,
30 Behind,
32 Missing,
34 Diverged,
36 Untracked,
38 Deleted,
40 DeletedModified,
42 Blocked,
44 #[serde(skip)]
46 Gone,
47}
48
49impl State {
50 fn label(self) -> &'static str {
51 match self {
52 State::Clean => "same",
53 State::Modified => "changed here",
54 State::Behind => "newer on the server",
55 State::Missing => "not here",
56 State::Diverged => "changed on both",
57 State::Untracked => "different, never pulled",
58 State::Deleted | State::DeletedModified | State::Gone => "deleted on the server",
59 State::Blocked => "can't be here",
60 }
61 }
62}
63
64#[derive(Debug, Serialize)]
66pub struct Entry {
67 pub path: String,
68 pub state: State,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub id: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub here: Option<i64>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub server: Option<i64>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub reason: Option<String>,
79}
80
81struct Item {
83 key: String,
84 on_server: Option<DocSummary>,
85 record: Option<Record>,
86 state: State,
87 reason: Option<String>,
88 detail: Option<DocDetail>,
90}
91
92impl Item {
93 fn entry(&self) -> Entry {
94 Entry {
95 path: self.key.clone(),
96 state: self.state,
97 id: self
98 .on_server
99 .as_ref()
100 .map(|s| s.id.clone())
101 .or_else(|| self.record.as_ref().map(|r| r.id.clone())),
102 here: self.record.as_ref().map(|r| r.rev),
103 server: self
104 .detail
105 .as_ref()
106 .map(|d| d.summary.rev)
107 .or_else(|| self.on_server.as_ref().map(|s| s.rev)),
108 reason: self.reason.clone(),
109 }
110 }
111}
112
113fn scope(path: Option<&Path>) -> anyhow::Result<(Link, Option<String>)> {
116 let start = match path {
117 Some(p) => std::path::absolute(p).with_context(|| format!("cannot read {}", shown(p)))?,
118 None => std::env::current_dir().context("reading the current folder")?,
119 };
120 let mut existing = start.as_path();
122 let mut tail = Vec::new();
123 while !existing.exists() {
124 let name = existing
125 .file_name()
126 .with_context(|| format!("{} does not exist", shown(&start)))?;
127 tail.push(name.to_owned());
128 existing = existing
129 .parent()
130 .with_context(|| format!("{} does not exist", shown(&start)))?;
131 }
132 let mut full = std::fs::canonicalize(existing)
133 .with_context(|| format!("cannot read {}", shown(existing)))?;
134 let link = project_link::find(&full)?.with_context(|| {
135 format!(
136 "{} is not in a project: no {} here or in a parent folder. Run `semora project init` to make one.",
137 shown(&full),
138 project_link::FILE
139 )
140 })?;
141 for name in tail.into_iter().rev() {
142 full.push(name);
143 }
144 let rel = full.strip_prefix(&link.root).with_context(|| {
145 format!(
146 "{} is not inside the project at {}",
147 shown(&full),
148 shown(&link.root)
149 )
150 })?;
151 let mut parts = Vec::new();
152 for c in rel.components() {
153 let s = c
154 .as_os_str()
155 .to_str()
156 .with_context(|| format!("{} isn't valid Unicode", shown(&full)))?;
157 parts.push(s.to_string());
158 }
159 Ok((link, (!parts.is_empty()).then(|| parts.join("/"))))
160}
161
162fn in_scope(key: &str, prefix: Option<&str>) -> bool {
164 match prefix {
165 None => true,
166 Some(p) => {
167 key == p
168 || key.strip_prefix(p).is_some_and(|r| r.starts_with('/'))
169 || p.strip_prefix(key).is_some_and(|r| r.starts_with('/'))
170 }
171 }
172}
173
174fn server_files(key: &str, d: &DocDetail) -> Result<BTreeMap<String, String>, String> {
176 let mut out = BTreeMap::new();
177 if d.summary.kind == DocKind::Bundle {
178 for f in &d.files {
179 let local = format!("{key}/{}", f.path);
180 if checkout::local_rel(&local).is_none() {
181 return Err(format!(
182 "the server lists a file `{}` that can't be written here",
183 f.path.escape_default()
184 ));
185 }
186 out.insert(local, f.hash.clone());
187 }
188 } else {
189 let entry = d
190 .files
191 .iter()
192 .find(|f| f.path == d.entry_path)
193 .or(d.files.first())
194 .ok_or_else(|| "the server lists no file for it".to_string())?;
195 out.insert(key.to_string(), entry.hash.clone());
196 }
197 Ok(out)
198}
199
200fn wrong_type(kind: DocKind) -> String {
202 if kind == DocKind::Bundle {
203 "it is a folder on the server, and a file is here".into()
204 } else {
205 "it is a file on the server, and a folder is here (or its path runs through a file)".into()
206 }
207}
208
209async fn survey(client: &Client, link: &Link, prefix: Option<&str>) -> anyhow::Result<Vec<Item>> {
211 let docs = client.project_docs(&link.slug).await?;
212 let records = checkout::load(link)?;
213 let on_server: BTreeMap<String, DocSummary> = docs
214 .into_iter()
215 .filter_map(|d| Some((d.path.clone()?, d)))
216 .collect();
217 let keys: BTreeSet<&String> = on_server
218 .keys()
219 .chain(records.keys())
220 .filter(|k| in_scope(k, prefix))
221 .collect();
222 let mut items = Vec::new();
223 for key in keys {
224 let s = on_server.get(key).cloned();
225 let r = records.get(key).cloned();
226 let mut item = Item {
227 key: key.clone(),
228 on_server: s.clone(),
229 record: r.clone(),
230 state: State::Clean,
231 reason: None,
232 detail: None,
233 };
234 let kind = s.as_ref().map(|s| s.kind).or(r.as_ref().map(|r| r.kind));
235 let local = checkout::local(&link.root, key, kind.expect("on the server or recorded"))?;
236 let Some(s) = s else {
237 let r = r.expect("recorded");
238 item.state = match local {
239 Local::Missing => State::Gone,
240 Local::Files(f) if f == r.files => State::Deleted,
241 _ => State::DeletedModified,
242 };
243 items.push(item);
244 continue;
245 };
246 let prefix_of = format!("{key}/");
248 if let Some(inner) = on_server.keys().find(|k| k.starts_with(&prefix_of)) {
249 item.state = State::Blocked;
250 item.reason = Some(format!(
251 "`{inner}` is a document of its own inside its path, so it can't be a {} here",
252 if s.kind == DocKind::Bundle {
253 "folder"
254 } else {
255 "file"
256 }
257 ));
258 items.push(item);
259 continue;
260 }
261 if checkout::local_rel(key).is_none() {
262 item.state = State::Blocked;
263 item.reason = Some("its path can't be a file name here".into());
264 items.push(item);
265 continue;
266 }
267 item.state = match (local, &r) {
268 (Local::WrongType, _) => {
269 item.reason = Some(wrong_type(s.kind));
270 State::Blocked
271 }
272 (Local::Missing, _) => State::Missing,
273 (Local::Files(f), Some(r)) => {
274 let same = r.id == s.id && r.rev == s.rev;
275 match (same, f == r.files) {
276 (true, true) => State::Clean,
277 (true, false) => State::Modified,
278 (false, true) => State::Behind,
279 (false, false) => State::Diverged,
280 }
281 }
282 (Local::Files(f), None) => {
283 let d = client.doc(&s.id).await?;
285 let state = match server_files(key, &d) {
286 Ok(theirs) if theirs == f => State::Clean,
287 Ok(_) => State::Untracked,
288 Err(why) => {
289 item.reason = Some(why);
290 State::Blocked
291 }
292 };
293 item.detail = Some(d);
294 state
295 }
296 };
297 items.push(item);
298 }
299 Ok(items)
300}
301
302fn project_and_scope(
303 ctx: &Ctx,
304 path: Option<&Path>,
305) -> anyhow::Result<(Client, Link, Option<String>)> {
306 let client = ctx.client()?;
307 let (link, prefix) = scope(path)?;
308 link.check_server(&client)?;
309 Ok((client, link, prefix))
310}
311
312pub async fn status(ctx: &Ctx, path: Option<PathBuf>) -> anyhow::Result<i32> {
313 let (client, link, prefix) = project_and_scope(ctx, path.as_deref())?;
314 let items = survey(&client, &link, prefix.as_deref()).await?;
315 let entries: Vec<Entry> = items
316 .iter()
317 .filter(|i| i.state != State::Gone)
318 .map(Item::entry)
319 .collect();
320 if ctx.json {
321 output::json(&serde_json::json!({
322 "project": link.slug,
323 "folder": shown(&link.root),
324 "documents": entries,
325 }));
326 return Ok(exit::OK);
327 }
328 let open: Vec<&Entry> = entries.iter().filter(|e| e.state != State::Clean).collect();
329 println!("Project {} in {}", link.slug, shown(&link.root));
330 if open.is_empty() {
331 println!(
332 "Everything here matches the server ({}).",
333 count(entries.len(), "document")
334 );
335 return Ok(exit::OK);
336 }
337 let width = open
338 .iter()
339 .map(|e| e.state.label().len())
340 .max()
341 .unwrap_or(0);
342 for e in &open {
343 let revs = match (e.state, e.here, e.server) {
344 (State::Modified, Some(h), _) => format!(" (from r{h})"),
345 (State::Behind, Some(h), Some(s)) => format!(" (r{h} here, r{s} on the server)"),
346 (State::Diverged, Some(h), Some(s)) => format!(" (from r{h}; the server has r{s})"),
347 (State::Missing | State::Untracked, _, Some(s)) => format!(" (r{s})"),
348 (State::DeletedModified, _, _) => " (changed here)".into(),
349 _ => String::new(),
350 };
351 let why = e
352 .reason
353 .as_deref()
354 .map(|r| format!(": {r}"))
355 .unwrap_or_default();
356 println!(" {:<width$} {}{revs}{why}", e.state.label(), e.path);
357 }
358 let pull = open.iter().any(|e| {
359 matches!(
360 e.state,
361 State::Behind | State::Missing | State::Deleted | State::Diverged | State::Untracked
362 )
363 });
364 let push = open
365 .iter()
366 .any(|e| matches!(e.state, State::Modified | State::Diverged));
367 match (pull, push) {
368 (true, true) => println!(
369 "Pull to get what is newer on the server; merge what changed on both before you push it."
370 ),
371 (true, false) => println!("Run `semora pull` to get what is newer on the server."),
372 (false, true) => println!("Push what changed here."),
373 (false, false) => {}
374 }
375 Ok(exit::OK)
376}
377
378fn count(n: usize, what: &str) -> String {
379 if n == 1 {
380 format!("1 {what}")
381 } else {
382 format!("{n} {what}s")
383 }
384}
385
386#[derive(Debug, Serialize)]
388struct Done {
389 path: String,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 id: Option<String>,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 rev: Option<i64>,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 note: Option<String>,
397}
398
399#[derive(Debug, Default, Serialize)]
400struct Pulled {
401 written: Vec<Done>,
402 unchanged: Vec<Done>,
403 skipped: Vec<Done>,
404 deleted: Vec<Done>,
405}
406
407fn write_file(path: &Path, data: &[u8]) -> anyhow::Result<()> {
409 let dir = path.parent().expect("a path in the project folder");
410 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", shown(dir)))?;
411 let name = path
412 .file_name()
413 .expect("a file name")
414 .to_string_lossy()
415 .into_owned();
416 let tmp = dir.join(format!(".{name}.{:08x}.semora-tmp", rand::random::<u32>()));
417 std::fs::write(&tmp, data).with_context(|| format!("writing {}", shown(&tmp)))?;
418 if let Err(e) = std::fs::rename(&tmp, path) {
419 let _ = std::fs::remove_file(&tmp);
420 return Err(e).with_context(|| format!("writing {}", shown(path)));
421 }
422 Ok(())
423}
424
425fn hash_of(path: &Path) -> Option<String> {
427 std::fs::read(path).ok().map(|d| checkout::sha256(&d))
428}
429
430fn remove_tracked(root: &Path, rel: &str, stop: &Path) -> anyhow::Result<()> {
433 let Some(p) = checkout::local_rel(rel) else {
434 return Ok(());
435 };
436 let full = root.join(p);
437 match std::fs::remove_file(&full) {
438 Ok(()) => {}
439 Err(e) if e.kind() == ErrorKind::NotFound => {}
440 Err(e) => return Err(e).with_context(|| format!("removing {}", shown(&full))),
441 }
442 let mut dir = full.parent();
443 while let Some(d) = dir {
444 if !d.starts_with(stop) || std::fs::remove_dir(d).is_err() {
445 break;
446 }
447 dir = d.parent();
448 }
449 Ok(())
450}
451
452async fn write_doc(
456 client: &Client,
457 root: &Path,
458 item: &Item,
459) -> anyhow::Result<Result<Record, String>> {
460 let s = item.on_server.as_ref().expect("on the server");
461 let d = match &item.detail {
462 Some(d) => d.clone(),
463 None => client.doc(&s.id).await?,
464 };
465 let theirs = match server_files(&item.key, &d) {
466 Ok(t) => t,
467 Err(why) => return Ok(Err(why)),
468 };
469 let rev = d.summary.rev;
470 for (local, hash) in &theirs {
471 let path = root.join(checkout::local_rel(local).expect("checked by server_files"));
472 if hash_of(&path).as_deref() == Some(hash.as_str()) {
473 continue;
474 }
475 let in_doc = local
476 .strip_prefix(&item.key)
477 .and_then(|r| r.strip_prefix('/'))
478 .filter(|_| d.summary.kind == DocKind::Bundle);
479 let data = client.raw(&d.summary.id, in_doc, Some(rev)).await?;
480 if checkout::sha256(&data) != *hash {
481 bail!(
482 "the server sent {local} with other content than it listed; nothing of it was written. Try again."
483 );
484 }
485 write_file(&path, &data)?;
486 }
487 if let Some(r) = &item.record {
488 let stop = root.join(checkout::local_rel(&item.key).expect("checked"));
489 for (old, hash) in &r.files {
490 if theirs.contains_key(old) {
491 continue;
492 }
493 let Some(rel) = checkout::local_rel(old) else {
494 continue;
495 };
496 if hash_of(&root.join(rel)).as_deref() == Some(hash.as_str()) {
497 remove_tracked(root, old, &stop)?;
498 }
499 }
500 }
501 Ok(Ok(Record {
502 id: d.summary.id.clone(),
503 rev,
504 kind: d.summary.kind,
505 files: theirs,
506 }))
507}
508
509pub async fn pull(ctx: &Ctx, args: PullArgs) -> anyhow::Result<i32> {
510 let (client, link, prefix) = project_and_scope(ctx, args.path.as_deref())?;
511 let items = survey(&client, &link, prefix.as_deref()).await?;
512 let root = link.root.clone();
513 let mut out = Pulled::default();
514 for item in &items {
515 let e = item.entry();
516 let done = |note: Option<String>| Done {
517 path: e.path.clone(),
518 id: e.id.clone(),
519 rev: e.server.or(e.here),
520 note,
521 };
522 let write = match item.state {
523 State::Behind | State::Missing => true,
524 State::Diverged | State::Untracked => args.force,
525 _ => false,
526 };
527 if write {
528 match write_doc(&client, &root, item).await? {
529 Ok(record) => {
530 let rev = record.rev;
531 checkout::update(&link, |r| r.insert(item.key.clone(), record))?;
532 out.written.push(Done {
533 rev: Some(rev),
534 ..done(None)
535 });
536 }
537 Err(why) => out.skipped.push(done(Some(why))),
538 }
539 continue;
540 }
541 match item.state {
542 State::Clean => {
543 if item.record.is_none()
545 && let Some(d) = &item.detail
546 && let Ok(files) = server_files(&item.key, d)
547 {
548 let record = Record {
549 id: d.summary.id.clone(),
550 rev: d.summary.rev,
551 kind: d.summary.kind,
552 files,
553 };
554 checkout::update(&link, |r| r.insert(item.key.clone(), record))?;
555 }
556 out.unchanged.push(done(None));
557 }
558 State::Modified => out
559 .unchanged
560 .push(done(Some("changed here, not pushed yet".into()))),
561 State::Diverged => out.skipped.push(done(Some(format!(
562 "changed here and on the server (this folder has r{}, the server r{}). Merge the server's copy into it and push with --force, or pull --force to take the server's",
563 e.here.unwrap_or_default(),
564 e.server.unwrap_or_default()
565 )))),
566 State::Untracked => out.skipped.push(done(Some(
567 "already here and different from the server's, and this folder never pulled or pushed it. pull --force takes the server's; push --force keeps yours".into(),
568 ))),
569 State::Deleted => {
570 let r = item.record.as_ref().expect("recorded");
571 let stop = root.join(checkout::local_rel(&item.key).unwrap_or_default());
572 for f in r.files.keys() {
573 remove_tracked(&root, f, &stop)?;
574 }
575 checkout::update(&link, |recs| recs.remove(&item.key))?;
576 out.deleted.push(done(None));
577 }
578 State::DeletedModified => out.skipped.push(done(Some(
579 "deleted on the server, and changed here. Push it with --force to put it back, or delete it".into(),
580 ))),
581 State::Gone => {
582 checkout::update(&link, |recs| recs.remove(&item.key))?;
583 }
584 State::Blocked => out.skipped.push(done(item.reason.clone())),
585 State::Behind | State::Missing => unreachable!("written above"),
586 }
587 }
588 if ctx.json {
589 output::json(&out);
590 return Ok(exit::OK);
591 }
592 print_pulled(&link, &out);
593 Ok(exit::OK)
594}
595
596fn print_pulled(link: &Link, out: &Pulled) {
597 let r = |d: &Done| d.rev.map(|r| format!(" (r{r})")).unwrap_or_default();
598 if out.written.is_empty() && out.deleted.is_empty() && out.skipped.is_empty() {
599 println!(
600 "Nothing new in {}: {} the same as on the server.",
601 link.slug,
602 count(out.unchanged.len(), "document")
603 );
604 } else {
605 let done = output::out().done;
606 anstream::println!(
607 "{done}Pulled{done:#} {} into {}",
608 link.slug,
609 shown(&link.root)
610 );
611 for d in &out.written {
612 println!(" wrote {}{}", d.path, r(d));
613 }
614 for d in &out.deleted {
615 println!(" deleted {}", d.path);
616 }
617 for d in &out.skipped {
618 println!(
619 " skipped {}: {}",
620 d.path,
621 d.note.as_deref().unwrap_or("skipped")
622 );
623 }
624 if !out.unchanged.is_empty() {
625 println!(" {} unchanged", count(out.unchanged.len(), "document"));
626 }
627 }
628 let local: Vec<&str> = out
629 .unchanged
630 .iter()
631 .filter(|d| d.note.is_some())
632 .map(|d| d.path.as_str())
633 .collect();
634 if !local.is_empty() {
635 println!("Changed here, not pushed yet: {}", local.join(", "));
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 #[test]
644 fn scope_covers_the_path_what_is_under_it_and_a_bundle_that_holds_it() {
645 assert!(in_scope("docs/plan.md", None));
646 assert!(in_scope("docs/plan.md", Some("docs")));
647 assert!(in_scope("docs/plan.md", Some("docs/plan.md")));
648 assert!(in_scope("docs/site", Some("docs/site/index.md")));
649 assert!(!in_scope("docs-old/plan.md", Some("docs")));
650 assert!(!in_scope("docs/plan.md", Some("docs/plan")));
651 assert!(!in_scope("notes.md", Some("docs")));
652 }
653
654 #[test]
655 fn a_path_that_does_not_exist_yet_still_finds_its_project() {
656 let d = tempfile::tempdir().unwrap();
657 std::fs::write(
658 d.path().join(".semora"),
659 "server = \"https://a.example.com\"\nproject = \"p\"\n",
660 )
661 .unwrap();
662 let (link, prefix) = scope(Some(&d.path().join("docs/new/plan.md"))).unwrap();
663 assert_eq!(link.slug, "p");
664 assert_eq!(prefix.as_deref(), Some("docs/new/plan.md"));
665 let (_, whole) = scope(Some(d.path())).unwrap();
666 assert_eq!(whole, None);
667 let lonely = tempfile::tempdir().unwrap();
668 let err = scope(Some(lonely.path())).unwrap_err().to_string();
669 assert!(err.contains("project init"), "{err}");
670 }
671}