1use std::collections::BTreeMap;
4use std::fs;
5use std::io;
6use std::ops::Deref;
7use std::path::Component;
8use std::path::Path;
9use std::path::PathBuf;
10
11use ecow::EcoString;
12use serde::Deserialize;
13use thiserror::Error;
14use typst::syntax::package::PackageManifest;
15use typst::syntax::package::PackageSpec;
16use tytanic_utils::result::ResultEx;
17use tytanic_utils::result::io_not_found;
18
19use crate::TOOL_NAME;
20use crate::config::ProjectConfig;
21use crate::test::Id;
22
23mod vcs;
24
25pub use vcs::Kind as VcsKind;
26pub use vcs::Vcs;
27
28pub const MANIFEST_FILE: &str = "typst.toml";
31
32#[derive(Debug, Clone)]
35pub struct ShallowProject {
36 root: PathBuf,
37 vcs: Option<Vcs>,
38}
39
40impl ShallowProject {
41 pub fn new<P, V>(project: P, vcs: V) -> Self
45 where
46 P: Into<PathBuf>,
47 V: Into<Option<Vcs>>,
48 {
49 Self {
50 root: project.into(),
51 vcs: vcs.into(),
52 }
53 }
54
55 #[tracing::instrument(skip(dir) fields(dir = ?dir.as_ref()), ret)]
62 pub fn discover<P: AsRef<Path>>(
63 dir: P,
64 search_manifest: bool,
65 ) -> Result<Option<Self>, io::Error> {
66 let dir = dir.as_ref();
67
68 let mut project = search_manifest.then(|| dir.to_path_buf());
69 let mut vcs = None;
70
71 for dir in dir.ancestors() {
72 if project.is_none() && Project::exists_at(dir)? {
73 tracing::debug!(project_root = ?dir, "found project");
74 project = Some(dir.to_path_buf());
75 }
76
77 if vcs.is_none()
81 && let Some(kind) = Vcs::exists_at(dir)?
82 {
83 tracing::debug!(vcs = ?kind, root = ?dir, "found vcs");
84 vcs = Some(Vcs::new(dir.to_path_buf(), kind));
85 }
86
87 if project.is_some() && vcs.is_some() {
88 break;
89 }
90 }
91
92 let Some(project) = project else {
93 return Ok(None);
94 };
95
96 Ok(Some(Self { root: project, vcs }))
97 }
98}
99
100impl ShallowProject {
101 #[tracing::instrument]
103 pub fn load(self) -> Result<Project, LoadError> {
104 let manifest = self.parse_manifest()?;
105 let config = manifest
106 .as_ref()
107 .map(|m| self.parse_config(m))
108 .transpose()?
109 .flatten()
110 .unwrap_or_default();
111
112 let unit_test_template = self.read_unit_test_template(&config)?;
113
114 Ok(Project {
115 base: self,
116 manifest,
117 config,
118 unit_test_template,
119 })
120 }
121
122 #[tracing::instrument]
125 pub fn parse_manifest(&self) -> Result<Option<PackageManifest>, ManifestError> {
126 let manifest = fs::read_to_string(self.manifest_file())
127 .ignore(io_not_found)?
128 .as_deref()
129 .map(toml::from_str)
130 .transpose()?;
131
132 if let Some(manifest) = &manifest {
133 validate_manifest(&self.root, manifest)?;
134 }
135
136 Ok(manifest)
137 }
138
139 #[tracing::instrument]
142 pub fn parse_config(
143 &self,
144 manifest: &PackageManifest,
145 ) -> Result<Option<ProjectConfig>, ManifestError> {
146 let config = manifest
147 .tool
148 .sections
149 .get(TOOL_NAME)
150 .cloned()
151 .map(ProjectConfig::deserialize)
152 .transpose()?;
153
154 if let Some(config) = &config {
155 validate_config(&self.root, config)?;
156 }
157
158 Ok(config)
159 }
160
161 #[tracing::instrument]
164 pub fn read_unit_test_template(
165 &self,
166 config: &ProjectConfig,
167 ) -> Result<Option<String>, io::Error> {
168 let root = Path::new(&config.unit_tests_root);
169 let template = root.join("template.typ");
170
171 fs::read_to_string(template).ignore(io_not_found)
172 }
173}
174
175impl ShallowProject {
176 pub fn root(&self) -> &Path {
181 &self.root
182 }
183
184 pub fn manifest_file(&self) -> PathBuf {
186 self.root.join(MANIFEST_FILE)
187 }
188
189 pub fn vcs_root(&self) -> Option<&Path> {
194 self.vcs.as_ref().and_then(Vcs::root)
195 }
196}
197
198#[derive(Debug, Clone)]
202pub struct Project {
203 base: ShallowProject,
204 manifest: Option<PackageManifest>,
205 config: ProjectConfig,
206 unit_test_template: Option<String>,
207}
208
209impl Project {
210 pub fn new<P: Into<PathBuf>>(root: P) -> Self {
212 Self {
213 base: ShallowProject {
214 root: root.into(),
215 vcs: None,
216 },
217 manifest: None,
218 config: ProjectConfig::default(),
219 unit_test_template: None,
220 }
221 }
222
223 pub fn with_vcs(mut self, vcs: Option<Vcs>) -> Self {
225 self.base.vcs = vcs;
226 self
227 }
228
229 pub fn with_manifest(mut self, manifest: Option<PackageManifest>) -> Self {
231 self.manifest = manifest;
232 self
233 }
234
235 pub fn with_config(mut self, config: ProjectConfig) -> Self {
237 self.config = config;
238 self
239 }
240
241 pub fn with_unit_test_template(mut self, unit_test_template: Option<String>) -> Self {
243 self.unit_test_template = unit_test_template;
244 self
245 }
246
247 pub fn exists_at(dir: &Path) -> io::Result<bool> {
250 if dir.join(MANIFEST_FILE).try_exists()? {
251 return Ok(true);
252 }
253
254 Ok(false)
255 }
256}
257
258impl Project {
259 pub fn base(&self) -> &ShallowProject {
261 &self.base
262 }
263
264 pub fn manifest(&self) -> Option<&PackageManifest> {
266 self.manifest.as_ref()
267 }
268
269 pub fn package_spec(&self) -> Option<PackageSpec> {
272 self.manifest.as_ref().map(|m| PackageSpec {
273 namespace: "preview".into(),
274 name: m.package.name.clone(),
275 version: m.package.version,
276 })
277 }
278
279 pub fn config(&self) -> &ProjectConfig {
281 &self.config
282 }
283
284 pub fn unit_test_template(&self) -> Option<&str> {
287 self.unit_test_template.as_deref()
288 }
289
290 pub fn vcs(&self) -> Option<&Vcs> {
293 self.base.vcs.as_ref()
294 }
295}
296
297impl Project {
298 pub fn unit_tests_root(&self) -> PathBuf {
303 self.root().join(&self.config.unit_tests_root)
304 }
305
306 pub fn template_root(&self) -> Option<PathBuf> {
308 self.manifest
309 .as_ref()
310 .and_then(|m| m.template.as_ref())
311 .map(|t| self.root().join(t.path.as_str()))
312 }
313
314 pub fn template_entrypoint(&self) -> Option<PathBuf> {
316 self.manifest
317 .as_ref()
318 .and_then(|m| m.template.as_ref())
319 .map(|t| {
320 let mut root = self.root().to_path_buf();
321 root.push(t.path.as_str());
322 root.push(t.entrypoint.as_str());
323 root
324 })
325 }
326
327 pub fn unit_test_template_file(&self) -> PathBuf {
330 let mut dir = self.unit_tests_root();
331 dir.push("template.typ");
332 dir
333 }
334
335 pub fn unit_test_dir(&self, id: &Id) -> PathBuf {
337 let mut dir = self.unit_tests_root();
338 dir.extend(id.components());
339 dir
340 }
341
342 pub fn unit_test_script(&self, id: &Id) -> PathBuf {
344 let mut dir = self.unit_test_dir(id);
345 dir.push("test.typ");
346 dir
347 }
348
349 pub fn unit_test_ref_script(&self, id: &Id) -> PathBuf {
351 let mut dir = self.unit_test_dir(id);
352 dir.push("ref.typ");
353 dir
354 }
355
356 pub fn unit_test_ref_dir(&self, id: &Id) -> PathBuf {
358 let mut dir = self.unit_test_dir(id);
359 dir.push("ref");
360 dir
361 }
362
363 pub fn unit_test_out_dir(&self, id: &Id) -> PathBuf {
365 let mut dir = self.unit_test_dir(id);
366 dir.push("out");
367 dir
368 }
369
370 pub fn unit_test_diff_dir(&self, id: &Id) -> PathBuf {
372 let mut dir = self.unit_test_dir(id);
373 dir.push("diff");
374 dir
375 }
376}
377
378impl Deref for Project {
379 type Target = ShallowProject;
380
381 fn deref(&self) -> &Self::Target {
382 self.base()
383 }
384}
385
386fn validate_manifest(root: &Path, manifest: &PackageManifest) -> Result<(), ValidationError> {
387 let PackageManifest {
388 package: _,
389 template,
390 tool: _,
391 unknown_fields: _,
392 } = manifest;
393
394 let Some(template) = template else {
395 return Ok(());
396 };
397
398 let mut error = ValidationError {
399 errors: BTreeMap::new(),
400 };
401
402 if !is_trivial_path(template.path.as_str()) {
403 error.errors.insert(
404 "template.path".into(),
405 ValidationErrorCause::NonTrivialPath {
406 field: template.path.clone(),
407 },
408 );
409 } else {
410 let path = root.join(template.path.as_str());
411
412 if !path.exists() {
413 error.errors.insert(
414 "template.path".into(),
415 ValidationErrorCause::DoesNotExist {
416 field: template.path.clone(),
417 resolved: path,
418 },
419 );
420 }
421 }
422
423 if !is_trivial_path(template.entrypoint.as_str()) {
424 error.errors.insert(
425 "template.entrypoint".into(),
426 ValidationErrorCause::NonTrivialPath {
427 field: template.entrypoint.clone(),
428 },
429 );
430 } else {
431 let mut path = root.join(template.path.as_str());
432 path.push(template.entrypoint.as_str());
433
434 if !path.exists() {
435 error.errors.insert(
436 "template.entrypoint".into(),
437 ValidationErrorCause::DoesNotExist {
438 field: template.entrypoint.clone(),
439 resolved: path,
440 },
441 );
442 }
443 }
444
445 if !error.errors.is_empty() {
446 return Err(error);
447 }
448
449 Ok(())
450}
451
452fn validate_config(root: &Path, config: &ProjectConfig) -> Result<(), ValidationError> {
453 let ProjectConfig {
454 unit_tests_root,
455 defaults: _,
456 } = config;
457
458 let mut error = ValidationError {
459 errors: BTreeMap::new(),
460 };
461
462 if !is_trivial_path(unit_tests_root.as_str()) {
463 error.errors.insert(
464 "tests".into(),
465 ValidationErrorCause::NonTrivialPath {
466 field: unit_tests_root.into(),
467 },
468 );
469 } else {
470 let path = root.join(unit_tests_root);
471
472 if !path.exists() {
473 error.errors.insert(
474 "tests".into(),
475 ValidationErrorCause::DoesNotExist {
476 field: unit_tests_root.into(),
477 resolved: path,
478 },
479 );
480 }
481 }
482
483 if !error.errors.is_empty() {
484 return Err(error);
485 }
486
487 Ok(())
488}
489
490fn is_trivial_path<P: AsRef<Path>>(path: P) -> bool {
491 let path = path.as_ref();
492 path.is_relative() && path.components().all(|c| matches!(c, Component::Normal(_)))
493}
494
495#[derive(Debug, Error)]
497pub enum LoadError {
498 #[error("an error occurred while parsing the project manifest")]
500 Manifest(#[from] ManifestError),
501
502 #[error("an error occurred while parsing the project config")]
504 Config(#[from] ConfigError),
505
506 #[error("an io error occurred")]
508 Io(#[from] io::Error),
509}
510
511#[derive(Debug, Error)]
513#[error("encountered {} errors while validating", errors.len())]
514pub struct ValidationError {
515 pub errors: BTreeMap<EcoString, ValidationErrorCause>,
517}
518
519#[derive(Debug, Error, Clone, PartialEq, Eq, Hash)]
521pub enum ValidationErrorCause {
522 #[error("the path was invalid: {field:?}")]
525 NonTrivialPath {
526 field: EcoString,
528 },
529
530 #[error("the path did not exist: {field:?} ({resolved:?})")]
532 DoesNotExist {
533 field: EcoString,
535
536 resolved: PathBuf,
538 },
539}
540
541#[derive(Debug, Error)]
543pub enum ConfigError {
544 #[error("an error occurred while validating project config")]
546 Invalid(#[from] ValidationError),
547
548 #[error("an error occurred while parsing the project config")]
550 Parse(#[from] toml::de::Error),
551
552 #[error("an io error occurred")]
554 Io(#[from] io::Error),
555}
556
557#[derive(Debug, Error)]
559pub enum ManifestError {
560 #[error("an error occurred while validating project manifest")]
562 Invalid(#[from] ValidationError),
563
564 #[error("an error occurred while parsing the project manifest")]
566 Parse(#[from] toml::de::Error),
567
568 #[error("an io error occurred")]
570 Io(#[from] io::Error),
571}
572
573#[cfg(test)]
574mod tests {
575 use tytanic_utils::fs::TempTestEnv;
576 use tytanic_utils::typst::PackageManifestBuilder;
577 use tytanic_utils::typst::TemplateInfoBuilder;
578
579 use super::*;
580
581 #[test]
582 fn test_template_paths() {
583 let project = Project::new("root").with_manifest(Some(
584 PackageManifestBuilder::new()
585 .template(
586 TemplateInfoBuilder::new()
587 .path("foo")
588 .entrypoint("bar.typ")
589 .build(),
590 )
591 .build(),
592 ));
593
594 assert_eq!(
595 project.template_root(),
596 Some(PathBuf::from_iter(["root", "foo"]))
597 );
598 assert_eq!(
599 project.template_entrypoint(),
600 Some(PathBuf::from_iter(["root", "foo", "bar.typ"]))
601 );
602 }
603
604 #[test]
605 fn test_unit_test_paths() {
606 let project = Project::new("root");
607 let id = Id::new("a/b").unwrap();
608
609 assert_eq!(
610 project.unit_tests_root(),
611 PathBuf::from_iter(["root", "tests"])
612 );
613 assert_eq!(
614 project.unit_test_dir(&id),
615 PathBuf::from_iter(["root", "tests", "a", "b"])
616 );
617 assert_eq!(
618 project.unit_test_script(&id),
619 PathBuf::from_iter(["root", "tests", "a", "b", "test.typ"])
620 );
621
622 let project = Project::new("root").with_config(ProjectConfig {
623 unit_tests_root: "foo".into(),
624 ..Default::default()
625 });
626
627 assert_eq!(
628 project.unit_test_ref_script(&id),
629 PathBuf::from_iter(["root", "foo", "a", "b", "ref.typ"])
630 );
631 assert_eq!(
632 project.unit_test_ref_dir(&id),
633 PathBuf::from_iter(["root", "foo", "a", "b", "ref"])
634 );
635 assert_eq!(
636 project.unit_test_out_dir(&id),
637 PathBuf::from_iter(["root", "foo", "a", "b", "out"])
638 );
639 assert_eq!(
640 project.unit_test_diff_dir(&id),
641 PathBuf::from_iter(["root", "foo", "a", "b", "diff"])
642 );
643 }
644
645 #[test]
646 fn test_validation_default() {
647 TempTestEnv::run_no_check(
648 |root| root.setup_dir("tests"),
649 |root| {
650 let config = ProjectConfig::default();
651 validate_config(root, &config).unwrap();
652 },
653 );
654 }
655
656 #[test]
657 fn test_validation_trivial_existing_paths() {
658 TempTestEnv::run_no_check(
659 |root| root.setup_dir("qux").setup_file_empty("foo/bar.typ"),
660 |root| {
661 let manifest = PackageManifestBuilder::new()
662 .template(
663 TemplateInfoBuilder::new()
664 .path("foo")
665 .entrypoint("bar.typ")
666 .build(),
667 )
668 .build();
669
670 let config = ProjectConfig {
671 unit_tests_root: "qux".into(),
672 ..Default::default()
673 };
674
675 validate_manifest(root, &manifest).unwrap();
676 validate_config(root, &config).unwrap();
677 },
678 );
679 }
680
681 #[test]
682 fn test_validation_non_trivial_paths() {
683 TempTestEnv::run_no_check(
684 |root| root,
685 |root| {
686 let manifest = PackageManifestBuilder::new()
687 .template(
688 TemplateInfoBuilder::new()
689 .path("..")
690 .entrypoint(".")
691 .build(),
692 )
693 .build();
694
695 let config = ProjectConfig {
696 unit_tests_root: "/.".into(),
697 ..Default::default()
698 };
699
700 let manifest = validate_manifest(root, &manifest).unwrap_err();
701 let config = validate_config(root, &config).unwrap_err();
702
703 assert_eq!(manifest.errors.len(), 2);
704 assert_eq!(config.errors.len(), 1);
705
706 assert_eq!(
707 manifest.errors.get("template.path").unwrap(),
708 &ValidationErrorCause::NonTrivialPath { field: "..".into() }
709 );
710 assert_eq!(
711 manifest.errors.get("template.entrypoint").unwrap(),
712 &ValidationErrorCause::NonTrivialPath { field: ".".into() }
713 );
714 assert_eq!(
715 config.errors.get("tests").unwrap(),
716 &ValidationErrorCause::NonTrivialPath { field: "/.".into() }
717 );
718 },
719 );
720 }
721
722 #[test]
723 fn test_validation_non_existent_paths() {
724 TempTestEnv::run_no_check(
725 |root| root,
726 |root| {
727 let manifest = PackageManifestBuilder::new()
728 .template(
729 TemplateInfoBuilder::new()
730 .path("foo")
731 .entrypoint("bar.typ")
732 .build(),
733 )
734 .build();
735
736 let config = ProjectConfig {
737 unit_tests_root: "qux".into(),
738 ..Default::default()
739 };
740
741 let manifest = validate_manifest(root, &manifest).unwrap_err();
742 let config = validate_config(root, &config).unwrap_err();
743
744 assert_eq!(manifest.errors.len(), 2);
745 assert_eq!(config.errors.len(), 1);
746
747 assert_eq!(
748 manifest.errors.get("template.path").unwrap(),
749 &ValidationErrorCause::DoesNotExist {
750 field: "foo".into(),
751 resolved: root.join("foo")
752 }
753 );
754 assert_eq!(
755 manifest.errors.get("template.entrypoint").unwrap(),
756 &ValidationErrorCause::DoesNotExist {
757 field: "bar.typ".into(),
758 resolved: root.join("foo/bar.typ")
759 }
760 );
761 assert_eq!(
762 config.errors.get("tests").unwrap(),
763 &ValidationErrorCause::DoesNotExist {
764 field: "qux".into(),
765 resolved: root.join("qux")
766 }
767 );
768 },
769 );
770 }
771}