1use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use std::time::SystemTime;
6
7use serde_json::{Map, Value};
8
9use crate::archive::{MANIFEST_NAME, MdzArchive};
10use crate::entrypoint::resolve_entry_point;
11use crate::error::{ErrorCode, MdzError, Result, err};
12use crate::filters::{DEFAULT_FILTERS, matches_filters};
13use crate::manifest::{
14 Manifest, Mode, SPEC_NAME, SPEC_VERSION, entry_point_value_problem, parse_manifest, serialize_manifest,
15 set_modified,
16};
17use crate::paths::{find_path, is_markdown, nfc, strip_dot_slash, validate_entry_set, validate_path};
18use crate::time::Utc;
19use crate::zip::{WriteEntry, write_zip};
20
21pub const PRODUCER_CORE_NAME: &str = "mdzip-core-rust";
23const PRODUCER_CORE_URL: &str = "https://github.com/mdzip-project/mdzip-core-rust";
24
25fn producer_core() -> Value {
26 let mut core = Map::new();
27 core.insert("name".into(), PRODUCER_CORE_NAME.into());
28 core.insert("version".into(), env!("CARGO_PKG_VERSION").into());
29 core.insert("url".into(), PRODUCER_CORE_URL.into());
30 Value::Object(core)
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct Application {
36 pub name: String,
38 pub version: Option<String>,
40 pub url: Option<String>,
42}
43
44impl Application {
45 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
47 Application {
48 name: name.into(),
49 version: Some(version.into()),
50 url: None,
51 }
52 }
53
54 pub fn url(mut self, url: impl Into<String>) -> Self {
56 self.url = Some(url.into());
57 self
58 }
59
60 fn to_value(&self) -> Value {
61 let mut o = Map::new();
62 o.insert("name".into(), self.name.clone().into());
63 if let Some(v) = &self.version {
64 o.insert("version".into(), v.clone().into());
65 }
66 if let Some(u) = &self.url {
67 o.insert("url".into(), u.clone().into());
68 }
69 Value::Object(o)
70 }
71}
72
73#[derive(Clone, Debug, Default)]
75pub struct CreateOptions {
76 pub title: Option<String>,
79 pub mode: Option<Mode>,
81 pub entry_point: Option<String>,
83 pub manifest: Option<Manifest>,
86 pub application: Option<Application>,
88 pub when: Option<SystemTime>,
90 pub keep_line_endings: bool,
94 pub store_only: bool,
96}
97
98impl CreateOptions {
99 pub fn new() -> Self {
101 Self::default()
102 }
103 pub fn title(mut self, title: impl Into<String>) -> Self {
105 self.title = Some(title.into());
106 self
107 }
108 pub fn mode(mut self, mode: Mode) -> Self {
110 self.mode = Some(mode);
111 self
112 }
113 pub fn entry_point(mut self, path: impl Into<String>) -> Self {
115 self.entry_point = Some(path.into());
116 self
117 }
118 pub fn manifest(mut self, manifest: Manifest) -> Self {
120 self.manifest = Some(manifest);
121 self
122 }
123 pub fn application(mut self, application: Application) -> Self {
125 self.application = Some(application);
126 self
127 }
128 pub fn when(mut self, when: SystemTime) -> Self {
130 self.when = Some(when);
131 self
132 }
133 pub fn keep_line_endings(mut self) -> Self {
135 self.keep_line_endings = true;
136 self
137 }
138 pub fn store_only(mut self) -> Self {
140 self.store_only = true;
141 self
142 }
143}
144
145fn gets_lf(path: &str) -> bool {
147 is_markdown(path) || path == MANIFEST_NAME
148}
149
150fn to_lf(data: &[u8]) -> Vec<u8> {
151 let mut out = Vec::with_capacity(data.len());
152 let mut i = 0;
153 while i < data.len() {
154 if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
155 i += 1;
156 continue;
157 }
158 out.push(data[i]);
159 i += 1;
160 }
161 out
162}
163
164fn normalize_files<P, C>(
166 files: impl IntoIterator<Item = (P, C)>,
167 keep_line_endings: bool,
168) -> Result<Vec<(String, Vec<u8>)>>
169where
170 P: AsRef<str>,
171 C: AsRef<[u8]>,
172{
173 let mut out: Vec<(String, Vec<u8>)> = Vec::new();
174 let mut seen = HashSet::new();
175 for (raw, content) in files {
176 let raw = raw.as_ref();
177 let path = nfc(raw);
179 validate_path(&path)?;
180 if path.ends_with('/') {
181 return err(
182 ErrorCode::PathInvalid,
183 format!("'{raw}' ends in '/'; a file path can't name a directory"),
184 );
185 }
186 if !seen.insert(path.clone()) {
187 return err(
188 ErrorCode::DuplicatePath,
189 format!("'{raw}' duplicates another path after NFC normalization"),
190 );
191 }
192 let content = content.as_ref();
193 let data = if !keep_line_endings && gets_lf(&path) {
194 to_lf(content)
195 } else {
196 content.to_vec()
197 };
198 out.push((path, data));
199 }
200 Ok(out)
201}
202
203fn first_heading(markdown: &[u8]) -> Option<String> {
205 let text = String::from_utf8_lossy(markdown);
206 text.lines().find_map(|line| {
207 let rest = line.strip_prefix('#')?;
208 if !rest.starts_with([' ', '\t']) {
209 return None;
210 }
211 let mut title = rest.trim();
212 let without = title.trim_end_matches('#');
214 if without.len() != title.len() && (without.is_empty() || without.ends_with([' ', '\t'])) {
215 title = without.trim_end();
216 }
217 (!title.is_empty()).then(|| title.to_owned())
218 })
219}
220
221fn file_stem(path: &str) -> String {
222 let name = path.rsplit('/').next().unwrap_or(path);
223 match name.rfind('.') {
224 Some(i) if i > 0 => name[..i].to_owned(),
225 _ => name.to_owned(),
226 }
227}
228
229fn choose_entry_point(files: &[(String, Vec<u8>)], requested: Option<&Value>) -> Result<String> {
230 let names = files.iter().map(|(p, _)| p.as_str());
231 match requested {
232 Some(value) => {
233 if let Some(problem) = entry_point_value_problem(value) {
234 return err(ErrorCode::EntryPointInvalid, problem);
235 }
236 let wanted = value.as_str().unwrap_or_default();
237 find_path(names, strip_dot_slash(wanted))
238 .map(str::to_owned)
239 .ok_or_else(|| {
240 MdzError::new(
241 ErrorCode::EntryPointMissing,
242 format!("entry point '{wanted}' is not among the files"),
243 )
244 })
245 }
246 None => resolve_entry_point(names, None),
247 }
248}
249
250pub fn create<P, C>(files: impl IntoIterator<Item = (P, C)>, options: &CreateOptions) -> Result<Vec<u8>>
273where
274 P: AsRef<str>,
275 C: AsRef<[u8]>,
276{
277 let mut contents = normalize_files(files, options.keep_line_endings)?;
278 let mut base: Manifest = options.manifest.clone().unwrap_or_default();
279 if let Some(i) = contents.iter().position(|(p, _)| p == MANIFEST_NAME) {
280 let (_, given) = contents.remove(i);
281 if options.manifest.is_none() {
282 base = parse_manifest(&given)?.manifest;
283 }
284 }
285
286 let mode = match options.mode {
287 Some(m) => m,
288 None => match base.get("mode") {
289 None => Mode::Document,
290 Some(v) => v
291 .as_str()
292 .and_then(Mode::parse)
293 .ok_or_else(|| MdzError::new(ErrorCode::ModeUnsupported, "mode must be \"document\" or \"project\""))?,
294 },
295 };
296 let requested = options.entry_point.clone().map(Value::String);
297 let entry_point = choose_entry_point(&contents, requested.as_ref().or_else(|| base.get("entryPoint")))?;
298 let stamp = Utc::from_system_time(options.when.unwrap_or_else(SystemTime::now));
299 let iso = stamp.rfc3339();
300
301 let mut out = Manifest::new();
302 let mut spec = base.get("spec").and_then(Value::as_object).cloned().unwrap_or_default();
303 spec.insert("name".into(), SPEC_NAME.into());
304 spec.insert("version".into(), SPEC_VERSION.into());
305 out.insert("spec".into(), Value::Object(spec));
306 let mut producer = base
307 .get("producer")
308 .and_then(Value::as_object)
309 .cloned()
310 .unwrap_or_default();
311 producer.insert("core".into(), producer_core());
312 if let Some(app) = &options.application {
313 producer.insert("application".into(), app.to_value());
314 }
315 out.insert("producer".into(), Value::Object(producer));
316 let title = options
317 .title
318 .clone()
319 .or_else(|| {
320 base.get("title")
321 .and_then(Value::as_str)
322 .filter(|s| !s.is_empty())
323 .map(str::to_owned)
324 })
325 .or_else(|| {
326 let data = &contents.iter().find(|(p, _)| *p == entry_point)?.1;
327 first_heading(data)
328 })
329 .unwrap_or_else(|| file_stem(&entry_point));
330 out.insert("title".into(), title.into());
331 out.insert("mode".into(), mode.as_str().into());
332 out.insert("entryPoint".into(), entry_point.into());
333 for (k, v) in &base {
334 if !out.contains_key(k) {
335 out.insert(k.clone(), v.clone());
336 }
337 }
338 if !out.contains_key("created") {
339 let mut created = Map::new();
340 created.insert("when".into(), iso.clone().into());
341 out.insert("created".into(), Value::Object(created));
342 }
343 set_modified(&mut out, &iso);
344
345 let date = stamp.dos();
346 let compress = !options.store_only;
347 let mut entries = vec![WriteEntry::file(
348 MANIFEST_NAME.into(),
349 &serialize_manifest(&out),
350 compress,
351 date,
352 )];
353 for (path, data) in &contents {
354 entries.push(WriteEntry::file(path.clone(), data, compress, date));
355 }
356 finish(&entries)
357}
358
359fn finish(entries: &[WriteEntry]) -> Result<Vec<u8>> {
360 validate_entry_set(entries.iter().map(|e| e.name.as_str()))?;
361 if let Some(e) = entries.iter().find(|e| e.name.len() > 0xFFFF) {
363 return err(
364 ErrorCode::PathInvalid,
365 format!("a path of {} bytes is longer than ZIP allows (65,535)", e.name.len()),
366 );
367 }
368 Ok(write_zip(entries))
369}
370
371pub fn create_from_directory(
378 directory: impl AsRef<Path>,
379 filters: Option<&[&str]>,
380 options: &CreateOptions,
381) -> Result<Vec<u8>> {
382 let root = directory.as_ref();
383 let filters = filters.unwrap_or(&DEFAULT_FILTERS);
384 let mut files = Vec::new();
385 let mut existing = None;
386 let io = |e: std::io::Error, p: &Path| MdzError::new(ErrorCode::Io, format!("'{}': {e}", p.display()));
387 let mut stack = vec![root.to_path_buf()];
388 while let Some(dir) = stack.pop() {
389 let mut items: Vec<_> = std::fs::read_dir(&dir)
390 .map_err(|e| io(e, &dir))?
391 .collect::<std::io::Result<_>>()
392 .map_err(|e| io(e, &dir))?;
393 items.sort_by_key(|e| e.file_name());
394 for item in items.into_iter().rev() {
395 let path = item.path();
396 let kind = item.file_type().map_err(|e| io(e, &path))?;
397 if kind.is_symlink() {
398 continue;
399 }
400 if kind.is_dir() {
401 stack.push(path);
402 continue;
403 }
404 let rel = path
405 .strip_prefix(root)
406 .unwrap_or(&path)
407 .components()
408 .map(|c| c.as_os_str().to_string_lossy())
409 .collect::<Vec<_>>()
410 .join("/");
411 if rel == MANIFEST_NAME {
412 existing = Some(parse_manifest(&std::fs::read(&path).map_err(|e| io(e, &path))?)?.manifest);
413 } else if matches_filters(&rel, filters) {
414 files.push((rel, std::fs::read(&path).map_err(|e| io(e, &path))?));
415 }
416 }
417 }
418 files.sort_by(|a, b| a.0.cmp(&b.0));
419 let mut options = options.clone();
420 if options.manifest.is_none() {
421 options.manifest = existing;
422 }
423 create(files, &options)
424}
425
426#[derive(Clone, Debug, Default)]
439pub struct Update {
440 write: Vec<(String, Vec<u8>)>,
441 remove: Vec<String>,
442 fields: Vec<(String, Option<Value>)>,
443 entry_point: Option<String>,
444 when: Option<SystemTime>,
445 keep_line_endings: bool,
446 drop_special_files: bool,
447}
448
449impl Update {
450 pub fn new() -> Self {
452 Self::default()
453 }
454 pub fn write(mut self, path: impl Into<String>, content: impl AsRef<[u8]>) -> Self {
456 self.write.push((path.into(), content.as_ref().to_vec()));
457 self
458 }
459 pub fn remove(mut self, path: impl Into<String>) -> Self {
461 self.remove.push(path.into());
462 self
463 }
464 pub fn set(mut self, field: impl Into<String>, value: impl Into<Value>) -> Self {
466 self.fields.push((field.into(), Some(value.into())));
467 self
468 }
469 pub fn unset(mut self, field: impl Into<String>) -> Self {
471 self.fields.push((field.into(), None));
472 self
473 }
474 pub fn entry_point(mut self, path: impl Into<String>) -> Self {
477 self.entry_point = Some(path.into());
478 self
479 }
480 pub fn when(mut self, when: SystemTime) -> Self {
482 self.when = Some(when);
483 self
484 }
485 pub fn keep_line_endings(mut self) -> Self {
487 self.keep_line_endings = true;
488 self
489 }
490 pub fn drop_special_files(mut self) -> Self {
492 self.drop_special_files = true;
493 self
494 }
495}
496
497enum Kept<'a> {
498 Copy(&'a crate::zip::ZipEntry),
499 New(Vec<u8>),
500}
501
502impl MdzArchive {
503 pub fn update(&self, changes: &Update) -> Result<Vec<u8>> {
520 let before = self.entry_point().ok();
521 let writes = normalize_files(changes.write.iter().map(|(p, c)| (p, c)), changes.keep_line_endings)?;
522 if writes.iter().any(|(p, _)| p == MANIFEST_NAME) {
523 return err(
524 ErrorCode::ManifestInvalid,
525 "change the manifest with Update::set, not by writing manifest.json",
526 );
527 }
528 let mut removals = HashSet::new();
529 for path in &changes.remove {
530 let stored = self
531 .find(path)
532 .or_else(|| find_path(self.special_files(), path))
533 .or_else(|| self.find_directory(path))
534 .ok_or_else(|| MdzError::new(ErrorCode::NotFound, format!("'{path}' is not in the archive")))?;
535 removals.insert(nfc(stored));
536 }
537 let mut write_map: HashMap<String, Vec<u8>> = HashMap::new();
538 let mut write_order = Vec::new();
539 for (p, data) in writes {
540 write_order.push(p.clone());
541 write_map.insert(p, data);
542 }
543
544 let mut entries: Vec<(String, Kept)> = Vec::new();
546 let mut placed = HashSet::new();
547 for e in &self.files {
548 let key = nfc(&e.name);
549 if e.name == MANIFEST_NAME || removals.contains(&key) {
550 continue;
551 }
552 if let Some(data) = write_map.get(&key) {
553 entries.push((key.clone(), Kept::New(data.clone())));
554 placed.insert(key);
555 } else {
556 self.read_entry(e)?; entries.push((e.name.clone(), Kept::Copy(e)));
558 }
559 }
560 for e in &self.directories {
561 if !removals.contains(&nfc(&e.name)) {
562 entries.push((e.name.clone(), Kept::Copy(e)));
563 }
564 }
565 let mut kept_special = HashSet::new();
566 if !changes.drop_special_files {
567 for e in &self.special {
568 let key = nfc(&e.name);
569 if !removals.contains(&key) && !write_map.contains_key(&key) {
570 entries.push((e.name.clone(), Kept::Copy(e)));
571 kept_special.insert(e.name.clone());
572 }
573 }
574 }
575 for p in &write_order {
576 if !placed.contains(p) {
577 entries.push((p.clone(), Kept::New(write_map[p].clone())));
578 }
579 }
580
581 let stamp = Utc::from_system_time(changes.when.unwrap_or_else(SystemTime::now));
582 let iso = stamp.rfc3339();
583 let mut manifest = self.manifest().cloned().unwrap_or_default();
584 if self.manifest().is_none() {
585 let mut spec = Map::new();
586 spec.insert("name".into(), SPEC_NAME.into());
587 spec.insert("version".into(), SPEC_VERSION.into());
588 manifest.insert("spec".into(), Value::Object(spec));
589 let mut producer = Map::new();
590 producer.insert("core".into(), producer_core());
591 manifest.insert("producer".into(), Value::Object(producer));
592 if let Some(ep) = &before {
593 manifest.insert("entryPoint".into(), ep.clone().into());
594 }
595 }
596 for (field, value) in &changes.fields {
597 match value {
598 Some(v) => {
599 manifest.insert(field.clone(), v.clone());
600 }
601 None => {
602 manifest.shift_remove(field);
603 }
604 }
605 }
606 if let Some(ep) = &changes.entry_point {
607 manifest.insert("entryPoint".into(), ep.clone().into());
608 }
609 set_modified(&mut manifest, &iso);
610
611 let paths = entries
613 .iter()
614 .map(|(p, _)| p.as_str())
615 .filter(|p| !kept_special.contains(*p) && !p.ends_with('/'));
616 let after = resolve_entry_point(paths, Some(&manifest)).map_err(|e| {
617 MdzError::new(
618 ErrorCode::EntryPointChanged,
619 format!("the update would leave the entry point unresolvable ({})", e.message()),
620 )
621 })?;
622 if changes.entry_point.is_none() {
623 if let Some(before) = &before {
624 if nfc(&after) != nfc(before) {
625 return err(
626 ErrorCode::EntryPointChanged,
627 format!("the update would change the entry point from '{before}' to '{after}'"),
628 );
629 }
630 }
631 }
632
633 let date = stamp.dos();
634 let mut out = vec![WriteEntry::file(
635 MANIFEST_NAME.into(),
636 &serialize_manifest(&manifest),
637 true,
638 date,
639 )];
640 for (path, kept) in entries {
641 out.push(match kept {
642 Kept::Copy(e) => WriteEntry::copy(path, e, self.as_bytes()),
643 Kept::New(data) => WriteEntry::file(path, &data, true, date),
644 });
645 }
646 finish(&out)
647 }
648}
649
650pub fn update(data: impl Into<Vec<u8>>, changes: &Update) -> Result<Vec<u8>> {
653 MdzArchive::open(data)?.update(changes)
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[test]
661 fn headings() {
662 assert_eq!(first_heading(b"intro\n# Guide ##\n").as_deref(), Some("Guide"));
663 assert_eq!(first_heading(b"# C#\n").as_deref(), Some("C#"));
664 assert_eq!(first_heading(b"#Tag\n## Sub\n").as_deref(), None);
665 }
666
667 #[test]
668 fn crlf_becomes_lf() {
669 assert_eq!(to_lf(b"a\r\nb\rc\n"), b"a\nb\rc\n");
670 }
671}