tytanic_core/project/
mod.rs

1//! Discovering, loading and managing typst projects.
2
3use 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
28/// The name of the manifest file which is used to discover the project root
29/// automatically.
30pub const MANIFEST_FILE: &str = "typst.toml";
31
32/// Represents a "shallow" unloaded project, it contains the base paths required
33/// to load a project.
34#[derive(Debug, Clone)]
35pub struct ShallowProject {
36    root: PathBuf,
37    vcs: Option<Vcs>,
38}
39
40impl ShallowProject {
41    /// Create a new project with the given roots.
42    ///
43    /// It is recommended to canonicalize them, but it is not strictly necessary.
44    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    /// Attempt to discover various paths for a directory.
56    ///
57    /// If `search_manifest` is `true`, then this will attempt to find the
58    /// project root by looking for a Typst manifest and return `None` if no
59    /// manifest is found. If it is `true`, then `dir` is used as the project
60    /// root.
61    #[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            // TODO(tinger): Currently we keep searching for a project even when
78            // we find a VCS root, I'm not sure if this makes sense, stopping at
79            // the VCS root is likely the most sensible behavior.
80            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    /// Loads the manifest, configuration, and unit test template of a project.
102    #[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    /// Parses the project manifest if it exists. Returns `None` if no
123    /// manifest is found.
124    #[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    /// Parses the manifest config from the tool section. Returns `None` if no
140    /// tool section found.
141    #[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    /// Reads the project's unit test template if it exists. Returns `None` if
162    /// no template was found.
163    #[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    /// Returns the path to the project root.
177    ///
178    /// The project root is used to resolve absolute paths in typst when
179    /// executing tests.
180    pub fn root(&self) -> &Path {
181        &self.root
182    }
183
184    /// Returns the path to the project manifest (`typst.toml`).
185    pub fn manifest_file(&self) -> PathBuf {
186        self.root.join(MANIFEST_FILE)
187    }
188
189    /// Returns the path to the VCS root.
190    ///
191    /// The VCS root is used for properly handling non-persistent storage of
192    /// tests.
193    pub fn vcs_root(&self) -> Option<&Path> {
194        self.vcs.as_ref().and_then(Vcs::root)
195    }
196}
197
198/// A fully loaded project, this can be constructed from [`ShallowProject`],
199/// which can be used to discover project paths without loading any
200/// configuration or manifests.
201#[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    /// Create a new empty project.
211    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    /// Attach a version control system to this project.
224    pub fn with_vcs(mut self, vcs: Option<Vcs>) -> Self {
225        self.base.vcs = vcs;
226        self
227    }
228
229    /// Attach a parsed manifest to this project.
230    pub fn with_manifest(mut self, manifest: Option<PackageManifest>) -> Self {
231        self.manifest = manifest;
232        self
233    }
234
235    /// Attach a parsed project config to this project.
236    pub fn with_config(mut self, config: ProjectConfig) -> Self {
237        self.config = config;
238        self
239    }
240
241    /// Attach a unit test template to this project.
242    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    /// Checks the given directory for a project root, returning `true` if it
248    /// was found.
249    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    /// Returns the shallow base object for this project.
260    pub fn base(&self) -> &ShallowProject {
261        &self.base
262    }
263
264    /// The fully parsed project manifest.
265    pub fn manifest(&self) -> Option<&PackageManifest> {
266        self.manifest.as_ref()
267    }
268
269    /// A package spec for this package itself, this is used by template tests
270    /// refer to themselves without attempting to download the package.
271    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    /// The fully parsed project config layer.
280    pub fn config(&self) -> &ProjectConfig {
281        &self.config
282    }
283
284    /// Returns the unit test template, that is, the source template to
285    /// use when generating new unit tests.
286    pub fn unit_test_template(&self) -> Option<&str> {
287        self.unit_test_template.as_deref()
288    }
289
290    /// Returns the [`Vcs`] this project is managed by or `None` if no supported
291    /// Vcs was found.
292    pub fn vcs(&self) -> Option<&Vcs> {
293        self.base.vcs.as_ref()
294    }
295}
296
297impl Project {
298    /// Returns the path to the test root. That is the path within the project
299    /// root where the test suite is located.
300    ///
301    /// The test root is used to resolve test identifiers.
302    pub fn unit_tests_root(&self) -> PathBuf {
303        self.root().join(&self.config.unit_tests_root)
304    }
305
306    /// Returns the root path of the template directory.
307    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    /// Returns the entrypoint script inside the template directory.
315    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    /// Returns the path to the unit test template, that is, the source template to
328    /// use when generating new unit tests.
329    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    /// Create a path to the test directory for the given identifier.
336    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    /// Create a path to the test script for the given identifier.
343    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    /// Create a path to the reference script for the given identifier.
350    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    /// Create a path to the reference directory for the given identifier.
357    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    /// Create a path to the output directory for the given identifier.
364    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    /// Create a path to the difference directory for the given identifier.
371    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/// Returned by [`ShallowProject::load`].
496#[derive(Debug, Error)]
497pub enum LoadError {
498    /// An error occurred while parsing the project manifest.
499    #[error("an error occurred while parsing the project manifest")]
500    Manifest(#[from] ManifestError),
501
502    /// An error occurred while parsing the project config.
503    #[error("an error occurred while parsing the project config")]
504    Config(#[from] ConfigError),
505
506    /// An IO error occurred.
507    #[error("an io error occurred")]
508    Io(#[from] io::Error),
509}
510
511/// Contained in [`ConfigError`] and [`ManifestError`].
512#[derive(Debug, Error)]
513#[error("encountered {} errors while validating", errors.len())]
514pub struct ValidationError {
515    /// The inner errors for each field.
516    pub errors: BTreeMap<EcoString, ValidationErrorCause>,
517}
518
519/// The cause for a validation error of an individual field.
520#[derive(Debug, Error, Clone, PartialEq, Eq, Hash)]
521pub enum ValidationErrorCause {
522    /// A path was not trivial when it must be, i.e. it contained components
523    /// such as `.` or `..`.
524    #[error("the path was invalid: {field:?}")]
525    NonTrivialPath {
526        /// The field as it was set in the config.
527        field: EcoString,
528    },
529
530    /// A configured path did not exist.
531    #[error("the path did not exist: {field:?} ({resolved:?})")]
532    DoesNotExist {
533        /// The field as it was set in the config.
534        field: EcoString,
535
536        /// The field as it was resolved.
537        resolved: PathBuf,
538    },
539}
540
541/// Returned by [`ShallowProject::parse_config`].
542#[derive(Debug, Error)]
543pub enum ConfigError {
544    /// An error occurred while validating the project config.
545    #[error("an error occurred while validating project config")]
546    Invalid(#[from] ValidationError),
547
548    /// An error occurred while parsing the project config.
549    #[error("an error occurred while parsing the project config")]
550    Parse(#[from] toml::de::Error),
551
552    /// An IO error occurred.
553    #[error("an io error occurred")]
554    Io(#[from] io::Error),
555}
556
557/// Returned by [`ShallowProject::parse_manifest`].
558#[derive(Debug, Error)]
559pub enum ManifestError {
560    /// An error occurred while validating the project manifest.
561    #[error("an error occurred while validating project manifest")]
562    Invalid(#[from] ValidationError),
563
564    /// An error occurred while parsing the project manifest.
565    #[error("an error occurred while parsing the project manifest")]
566    Parse(#[from] toml::de::Error),
567
568    /// An io error occurred.
569    #[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}