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<(project_link::FileLink, 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
302async fn project_and_scope(
304 ctx: &Ctx,
305 path: Option<&Path>,
306) -> anyhow::Result<(Client, Link, Option<String>)> {
307 let client = ctx.client()?;
308 let (file, prefix) = scope(path)?;
309 let link = project_link::resolve(&client, &ctx.settings.config_dir, file).await?;
310 let link = project_link::follow(&client, link).await?;
311 Ok((client, link, prefix))
312}
313
314pub async fn status(ctx: &Ctx, path: Option<PathBuf>) -> anyhow::Result<i32> {
315 let (client, link, prefix) = project_and_scope(ctx, path.as_deref()).await?;
316 let items = survey(&client, &link, prefix.as_deref()).await?;
317 let entries: Vec<Entry> = items
318 .iter()
319 .filter(|i| i.state != State::Gone)
320 .map(Item::entry)
321 .collect();
322 if ctx.json {
323 output::json(&serde_json::json!({
324 "project": link.slug,
325 "folder": shown(&link.root),
326 "documents": entries,
327 }));
328 return Ok(exit::OK);
329 }
330 let open: Vec<&Entry> = entries.iter().filter(|e| e.state != State::Clean).collect();
331 println!("Project {} in {}", link.slug, shown(&link.root));
332 if open.is_empty() {
333 println!(
334 "Everything here matches the server ({}).",
335 count(entries.len(), "document")
336 );
337 return Ok(exit::OK);
338 }
339 let width = open
340 .iter()
341 .map(|e| e.state.label().len())
342 .max()
343 .unwrap_or(0);
344 for e in &open {
345 let revs = match (e.state, e.here, e.server) {
346 (State::Modified, Some(h), _) => format!(" (from r{h})"),
347 (State::Behind, Some(h), Some(s)) => format!(" (r{h} here, r{s} on the server)"),
348 (State::Diverged, Some(h), Some(s)) => format!(" (from r{h}; the server has r{s})"),
349 (State::Missing | State::Untracked, _, Some(s)) => format!(" (r{s})"),
350 (State::DeletedModified, _, _) => " (changed here)".into(),
351 _ => String::new(),
352 };
353 let why = e
354 .reason
355 .as_deref()
356 .map(|r| format!(": {r}"))
357 .unwrap_or_default();
358 println!(" {:<width$} {}{revs}{why}", e.state.label(), e.path);
359 }
360 let pull = open.iter().any(|e| {
361 matches!(
362 e.state,
363 State::Behind | State::Missing | State::Deleted | State::Diverged | State::Untracked
364 )
365 });
366 let push = open
367 .iter()
368 .any(|e| matches!(e.state, State::Modified | State::Diverged));
369 match (pull, push) {
370 (true, true) => println!(
371 "Pull to get what is newer on the server; merge what changed on both before you push it."
372 ),
373 (true, false) => println!("Run `semora pull` to get what is newer on the server."),
374 (false, true) => println!("Push what changed here."),
375 (false, false) => {}
376 }
377 Ok(exit::OK)
378}
379
380fn count(n: usize, what: &str) -> String {
381 if n == 1 {
382 format!("1 {what}")
383 } else {
384 format!("{n} {what}s")
385 }
386}
387
388#[derive(Debug, Serialize)]
390struct Done {
391 path: String,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 id: Option<String>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 rev: Option<i64>,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 note: Option<String>,
399}
400
401#[derive(Debug, Default, Serialize)]
402struct Pulled {
403 written: Vec<Done>,
404 unchanged: Vec<Done>,
405 skipped: Vec<Done>,
406 deleted: Vec<Done>,
407}
408
409fn write_file(path: &Path, data: &[u8]) -> anyhow::Result<()> {
411 let dir = path.parent().expect("a path in the project folder");
412 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", shown(dir)))?;
413 let name = path
414 .file_name()
415 .expect("a file name")
416 .to_string_lossy()
417 .into_owned();
418 let tmp = dir.join(format!(".{name}.{:08x}.semora-tmp", rand::random::<u32>()));
419 std::fs::write(&tmp, data).with_context(|| format!("writing {}", shown(&tmp)))?;
420 if let Err(e) = std::fs::rename(&tmp, path) {
421 let _ = std::fs::remove_file(&tmp);
422 return Err(e).with_context(|| format!("writing {}", shown(path)));
423 }
424 Ok(())
425}
426
427fn hash_of(path: &Path) -> Option<String> {
429 std::fs::read(path).ok().map(|d| checkout::sha256(&d))
430}
431
432fn remove_tracked(root: &Path, rel: &str, stop: &Path) -> anyhow::Result<()> {
435 let Some(p) = checkout::local_rel(rel) else {
436 return Ok(());
437 };
438 let full = root.join(p);
439 match std::fs::remove_file(&full) {
440 Ok(()) => {}
441 Err(e) if e.kind() == ErrorKind::NotFound => {}
442 Err(e) => return Err(e).with_context(|| format!("removing {}", shown(&full))),
443 }
444 let mut dir = full.parent();
445 while let Some(d) = dir {
446 if !d.starts_with(stop) || std::fs::remove_dir(d).is_err() {
447 break;
448 }
449 dir = d.parent();
450 }
451 Ok(())
452}
453
454async fn write_doc(
458 client: &Client,
459 root: &Path,
460 item: &Item,
461) -> anyhow::Result<Result<Record, String>> {
462 let s = item.on_server.as_ref().expect("on the server");
463 let d = match &item.detail {
464 Some(d) => d.clone(),
465 None => client.doc(&s.id).await?,
466 };
467 let theirs = match server_files(&item.key, &d) {
468 Ok(t) => t,
469 Err(why) => return Ok(Err(why)),
470 };
471 let rev = d.summary.rev;
472 for (local, hash) in &theirs {
473 let path = root.join(checkout::local_rel(local).expect("checked by server_files"));
474 if hash_of(&path).as_deref() == Some(hash.as_str()) {
475 continue;
476 }
477 let in_doc = local
478 .strip_prefix(&item.key)
479 .and_then(|r| r.strip_prefix('/'))
480 .filter(|_| d.summary.kind == DocKind::Bundle);
481 let data = client.raw(&d.summary.id, in_doc, Some(rev)).await?;
482 if checkout::sha256(&data) != *hash {
483 bail!(
484 "the server sent {local} with other content than it listed; nothing of it was written. Try again."
485 );
486 }
487 write_file(&path, &data)?;
488 }
489 if let Some(r) = &item.record {
490 let stop = root.join(checkout::local_rel(&item.key).expect("checked"));
491 for (old, hash) in &r.files {
492 if theirs.contains_key(old) {
493 continue;
494 }
495 let Some(rel) = checkout::local_rel(old) else {
496 continue;
497 };
498 if hash_of(&root.join(rel)).as_deref() == Some(hash.as_str()) {
499 remove_tracked(root, old, &stop)?;
500 }
501 }
502 }
503 Ok(Ok(Record {
504 id: d.summary.id.clone(),
505 rev,
506 kind: d.summary.kind,
507 files: theirs,
508 }))
509}
510
511pub async fn pull(ctx: &Ctx, args: PullArgs) -> anyhow::Result<i32> {
512 let (client, link, prefix) = project_and_scope(ctx, args.path.as_deref()).await?;
513 let items = survey(&client, &link, prefix.as_deref()).await?;
514 let root = link.root.clone();
515 let mut out = Pulled::default();
516 for item in &items {
517 let e = item.entry();
518 let done = |note: Option<String>| Done {
519 path: e.path.clone(),
520 id: e.id.clone(),
521 rev: e.server.or(e.here),
522 note,
523 };
524 let write = match item.state {
525 State::Behind | State::Missing => true,
526 State::Diverged | State::Untracked => args.force,
527 _ => false,
528 };
529 if write {
530 match write_doc(&client, &root, item).await? {
531 Ok(record) => {
532 let rev = record.rev;
533 checkout::update(&link, |r| r.insert(item.key.clone(), record))?;
534 out.written.push(Done {
535 rev: Some(rev),
536 ..done(None)
537 });
538 }
539 Err(why) => out.skipped.push(done(Some(why))),
540 }
541 continue;
542 }
543 match item.state {
544 State::Clean => {
545 if item.record.is_none()
547 && let Some(d) = &item.detail
548 && let Ok(files) = server_files(&item.key, d)
549 {
550 let record = Record {
551 id: d.summary.id.clone(),
552 rev: d.summary.rev,
553 kind: d.summary.kind,
554 files,
555 };
556 checkout::update(&link, |r| r.insert(item.key.clone(), record))?;
557 }
558 out.unchanged.push(done(None));
559 }
560 State::Modified => out
561 .unchanged
562 .push(done(Some("changed here, not pushed yet".into()))),
563 State::Diverged => out.skipped.push(done(Some(format!(
564 "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",
565 e.here.unwrap_or_default(),
566 e.server.unwrap_or_default()
567 )))),
568 State::Untracked => out.skipped.push(done(Some(
569 "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(),
570 ))),
571 State::Deleted => {
572 let r = item.record.as_ref().expect("recorded");
573 let stop = root.join(checkout::local_rel(&item.key).unwrap_or_default());
574 for f in r.files.keys() {
575 remove_tracked(&root, f, &stop)?;
576 }
577 checkout::update(&link, |recs| recs.remove(&item.key))?;
578 out.deleted.push(done(None));
579 }
580 State::DeletedModified => out.skipped.push(done(Some(
581 "deleted on the server, and changed here. Push it with --force to put it back, or delete it".into(),
582 ))),
583 State::Gone => {
584 checkout::update(&link, |recs| recs.remove(&item.key))?;
585 }
586 State::Blocked => out.skipped.push(done(item.reason.clone())),
587 State::Behind | State::Missing => unreachable!("written above"),
588 }
589 }
590 if ctx.json {
591 output::json(&out);
592 return Ok(exit::OK);
593 }
594 print_pulled(&link, &out);
595 Ok(exit::OK)
596}
597
598fn print_pulled(link: &Link, out: &Pulled) {
599 let r = |d: &Done| d.rev.map(|r| format!(" (r{r})")).unwrap_or_default();
600 if out.written.is_empty() && out.deleted.is_empty() && out.skipped.is_empty() {
601 println!(
602 "Nothing new in {}: {} the same as on the server.",
603 link.slug,
604 count(out.unchanged.len(), "document")
605 );
606 } else {
607 let done = output::out().done;
608 anstream::println!(
609 "{done}Pulled{done:#} {} into {}",
610 link.slug,
611 shown(&link.root)
612 );
613 for d in &out.written {
614 println!(" wrote {}{}", d.path, r(d));
615 }
616 for d in &out.deleted {
617 println!(" deleted {}", d.path);
618 }
619 for d in &out.skipped {
620 println!(
621 " skipped {}: {}",
622 d.path,
623 d.note.as_deref().unwrap_or("skipped")
624 );
625 }
626 if !out.unchanged.is_empty() {
627 println!(" {} unchanged", count(out.unchanged.len(), "document"));
628 }
629 }
630 let local: Vec<&str> = out
631 .unchanged
632 .iter()
633 .filter(|d| d.note.is_some())
634 .map(|d| d.path.as_str())
635 .collect();
636 if !local.is_empty() {
637 println!("Changed here, not pushed yet: {}", local.join(", "));
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn scope_covers_the_path_what_is_under_it_and_a_bundle_that_holds_it() {
647 assert!(in_scope("docs/plan.md", None));
648 assert!(in_scope("docs/plan.md", Some("docs")));
649 assert!(in_scope("docs/plan.md", Some("docs/plan.md")));
650 assert!(in_scope("docs/site", Some("docs/site/index.md")));
651 assert!(!in_scope("docs-old/plan.md", Some("docs")));
652 assert!(!in_scope("docs/plan.md", Some("docs/plan")));
653 assert!(!in_scope("notes.md", Some("docs")));
654 }
655
656 #[test]
657 fn a_path_that_does_not_exist_yet_still_finds_its_project() {
658 let d = tempfile::tempdir().unwrap();
659 std::fs::write(
660 d.path().join(".semora"),
661 "server = \"https://a.example.com\"\nproject = \"p\"\n",
662 )
663 .unwrap();
664 let (link, prefix) = scope(Some(&d.path().join("docs/new/plan.md"))).unwrap();
665 assert_eq!(link.slug, "p");
666 assert_eq!(prefix.as_deref(), Some("docs/new/plan.md"));
667 let (_, whole) = scope(Some(d.path())).unwrap();
668 assert_eq!(whole, None);
669 let lonely = tempfile::tempdir().unwrap();
670 let err = scope(Some(lonely.path())).unwrap_err().to_string();
671 assert!(err.contains("project init"), "{err}");
672 }
673}