specfold_core/files/
attach.rs1use super::{hash, ignore_file, store};
5use crate::corpus::Corpus;
6use crate::markdown::frontmatter::edit;
7use crate::model::Doc;
8use anyhow::{Context, Result, bail};
9use serde::Serialize;
10use serde_json::{Value, json};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Where {
15 Auto,
17 Repo,
18 Store,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct Planned {
23 pub source: String,
24 pub path: String,
26 pub sha256: String,
27 pub bytes: u64,
28 pub store: String,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct Plan {
33 pub doc: String,
34 pub to_bundle: Option<(String, String)>,
36 pub files: Vec<Planned>,
37}
38
39fn bundle_main(doc: &Doc) -> (String, String) {
40 let file = doc.path.rsplit_once('/').map(|x| x.1).unwrap_or(&doc.path);
41 let dir = doc.base_dir();
42 let stem = file.trim_end_matches(".md").trim_end_matches(&format!(".{}", doc.id.ty));
43 let new_dir = format!("{dir}/{stem}");
44 (new_dir.clone(), format!("{new_dir}/index.{}.md", doc.id.ty))
45}
46
47pub fn plan(c: &Corpus, doc: &Doc, sources: &[PathBuf], into: &str, place: Where) -> Result<Plan> {
48 let limit = c.cfg.raw.files.inline_max_bytes;
49 let has_store = c.cfg.raw.files.store.is_some();
50 let to_bundle = match &doc.dir {
51 Some(_) => None,
52 None => Some((doc.path.clone(), bundle_main(doc).1)),
53 };
54 if into.starts_with('/')
56 || into.starts_with('\\')
57 || into.contains(':')
58 || into.split(['/', '\\']).any(|p| p == "..")
59 {
60 bail!("--into '{into}' must be a relative folder inside the bundle (no '..', no absolute path)");
61 }
62 let main = to_bundle.as_ref().map_or(doc.path.as_str(), |(_, to)| to.as_str());
64 let main_name = Path::new(main).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
65 let own = std::fs::canonicalize(c.cfg.root.join(&doc.path)).ok();
66 let mut files = vec![];
67 for src in sources {
68 let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
69 let rel = if into.is_empty() { name } else { format!("{}/{name}", into.trim_matches('/')) };
70 if own.is_some() && std::fs::canonicalize(src).ok() == own || rel == main_name {
71 bail!(
72 "{} is the document's own file ({main_name}), not an attachment; nothing was written; next: spec attach {} <other file> --write",
73 src.display(),
74 doc.id
75 );
76 }
77 let (sha256, bytes) = hash::file(src).with_context(|| format!("cannot read {}", src.display()))?;
78 let store = match place {
79 Where::Repo if bytes > limit => {
80 bail!("{} is {bytes} bytes, over the {limit}-byte limit for the repository", src.display())
81 }
82 Where::Repo => "repo",
83 Where::Store if !has_store => bail!("no [files.store] is configured"),
84 Where::Store => "store",
85 Where::Auto if bytes > limit && !has_store => {
86 bail!("{} is {bytes} bytes; configure [files.store] or shrink it under {limit}", src.display())
87 }
88 Where::Auto if bytes > limit => "store",
89 Where::Auto => "repo",
90 };
91 files.push(Planned {
92 source: src.to_string_lossy().into_owned(),
93 path: rel,
94 sha256,
95 bytes,
96 store: store.into(),
97 });
98 }
99 Ok(Plan { doc: doc.id.to_string(), to_bundle, files })
100}
101
102pub fn apply(c: &Corpus, doc: &Doc, plan: &Plan) -> Result<()> {
103 let root = &c.cfg.root;
104 let main = match &plan.to_bundle {
105 Some((from, to)) => {
106 std::fs::create_dir_all(root.join(to).parent().unwrap_or(root))?;
107 std::fs::rename(root.join(from), root.join(to))?;
108 to.clone()
109 }
110 None => doc.path.clone(),
111 };
112 let bundle = Path::new(&main).parent().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
113 let mut ignored = vec![];
114 for f in &plan.files {
115 let dst = root.join(&bundle).join(&f.path);
116 if let Some(p) = dst.parent() {
117 std::fs::create_dir_all(p)?;
118 }
119 let same =
121 std::fs::canonicalize(&f.source).ok().zip(std::fs::canonicalize(&dst).ok()).is_some_and(|(a, b)| a == b);
122 if !same {
123 std::fs::copy(&f.source, &dst).with_context(|| format!("cannot copy {} to {}", f.source, dst.display()))?;
124 }
125 if f.store == "store" {
126 let s = store::open(&c.cfg)?.context("no [files.store] is configured")?;
127 s.put(&dst, &f.sha256)?;
128 ignored.push(f.path.clone());
129 }
130 }
131 if !ignored.is_empty() {
132 ignore_file::add(&root.join(&bundle), &ignored)?;
133 }
134 let text = std::fs::read_to_string(root.join(&main))?;
135 let new = edit::rewrite(&text, |m| {
136 let mut list: Vec<Value> = m.get("files").and_then(|v| v.as_array()).cloned().unwrap_or_default();
137 for f in &plan.files {
138 list.retain(|v| v.get("path").and_then(Value::as_str) != Some(f.path.as_str()));
139 list.push(json!({"path": f.path, "sha256": f.sha256, "bytes": f.bytes, "store": f.store}));
140 }
141 m.insert("files".into(), Value::Array(list));
142 m.insert("updated".into(), Value::String(crate::model::stamp::now_value()));
143 })?;
144 std::fs::write(root.join(&main), new)?;
145 Ok(())
146}