tytanic_core/project/
mod.rs

1//! Discovering, loading and managing typst projects.
2
3use std::collections::BTreeMap;
4use std::ops::Deref;
5use std::path::Component;
6use std::path::{Path, PathBuf};
7use std::{fs, io};
8
9use ecow::EcoString;
10use serde::Deserialize;
11use thiserror::Error;
12use typst::syntax::package::PackageManifest;
13use tytanic_utils::result::{io_not_found, ResultEx};
14
15use crate::config::ProjectConfig;
16use crate::test::Id;
17use crate::TOOL_NAME;
18
19mod vcs;
20
21pub use vcs::{Kind as VcsKind, Vcs};
22
23/// The name of the manifest file which is used to discover the project root
24/// automatically.
25pub const MANIFEST_FILE: &str = "typst.toml";
26
27/// Represents a "shallow" unloaded project, it contains the base paths required
28/// to to load a project.
29#[derive(Debug, Clone)]
30pub struct ShallowProject {
31    root: PathBuf,
32    vcs: Option<Vcs>,
33}
34
35impl ShallowProject {
36    /// Create a new project with the given roots.
37    ///
38    /// It is recommended to canonicalize them, but it is not strictly necessary.
39    pub fn new<P, V>(project: P, vcs: V) -> Self
40    where
41        P: Into<PathBuf>,
42        V: Into<Option<Vcs>>,
43    {
44        Self {
45            root: project.into(),
46            vcs: vcs.into(),
47        }
48    }
49
50    /// Attempt to discover various paths for a directory.
51    ///
52    /// If `search_manifest` is `true`, then this will attempt to find the
53    /// project root by looking for a Typst manifest and return `None` if no
54    /// manifest is found. If it is `true`, then `dir` is used as the project
55    /// root.
56    pub fn discover<P: AsRef<Path>>(
57        dir: P,
58        search_manifest: bool,
59    ) -> Result<Option<Self>, io::Error> {
60        let dir = dir.as_ref();
61
62        let mut project = search_manifest.then(|| dir.to_path_buf());
63        let mut vcs = None;
64
65        for dir in dir.ancestors() {
66            if project.is_none() && Project::exists_at(dir)? {
67                tracing::debug!(project_root = ?dir, "found project");
68                project = Some(dir.to_path_buf());
69            }
70
71            // TODO(tinger): Currently we keep searching for a project even when
72            // we find a vcs root, I'm not sure if this makes sense, stopping at
73            // the vcs root is likely the most sensible behavior.
74            if vcs.is_none() {
75                if let Some(kind) = Vcs::exists_at(dir)? {
76                    tracing::debug!(vcs = ?kind, root = ?dir, "found vcs");
77                    vcs = Some(Vcs::new(dir.to_path_buf(), kind));
78                }
79            }
80
81            if project.is_some() && vcs.is_some() {
82                break;
83            }
84        }
85
86        let Some(project) = project else {
87            return Ok(None);
88        };
89
90        Ok(Some(Self { root: project, vcs }))
91    }
92}
93
94impl ShallowProject {
95    /// Loads the manifest, configuration and unit test template of a project.
96    pub fn load(self) -> Result<Project, LoadError> {
97        let manifest = self.parse_manifest()?;
98        let config = manifest
99            .as_ref()
100            .map(|m| self.parse_config(m))
101            .transpose()?
102            .flatten()
103            .unwrap_or_default();
104
105        let unit_test_template = self.read_unit_test_template(&config)?;
106
107        Ok(Project {
108            base: self,
109            manifest,
110            config,
111            unit_test_template,
112        })
113    }
114
115    /// Parses the project manifest if it exists. Returns `None` if no
116    /// manifest is found.
117    pub fn parse_manifest(&self) -> Result<Option<PackageManifest>, ManifestError> {
118        let manifest = fs::read_to_string(self.manifest_file())
119            .ignore(io_not_found)?
120            .as_deref()
121            .map(toml::from_str)
122            .transpose()?;
123
124        if let Some(manifest) = &manifest {
125            validate_manifest(manifest)?;
126        }
127
128        Ok(manifest)
129    }
130
131    /// Parses the manifest config from the tool section. Returns `None` if no
132    /// tool section found.
133    pub fn parse_config(
134        &self,
135        manifest: &PackageManifest,
136    ) -> Result<Option<ProjectConfig>, ManifestError> {
137        let config = manifest
138            .tool
139            .sections
140            .get(TOOL_NAME)
141            .cloned()
142            .map(ProjectConfig::deserialize)
143            .transpose()?;
144
145        if let Some(config) = &config {
146            validate_config(config)?;
147        }
148
149        Ok(config)
150    }
151
152    /// Reads the project's unit test template if it exists. Returns `None` if
153    /// no template was found.
154    pub fn read_unit_test_template(
155        &self,
156        config: &ProjectConfig,
157    ) -> Result<Option<String>, io::Error> {
158        let root = Path::new(&config.unit_tests_root);
159        let template = root.join("template.typ");
160
161        fs::read_to_string(template).ignore(io_not_found)
162    }
163}
164
165impl ShallowProject {
166    /// Returns the path to the project root.
167    ///
168    /// The project root is used to resolve absolute paths in typst when
169    /// executing tests.
170    pub fn root(&self) -> &Path {
171        &self.root
172    }
173
174    /// Returns the path to the project manifest (`typst.toml`).
175    pub fn manifest_file(&self) -> PathBuf {
176        self.root.join(MANIFEST_FILE)
177    }
178
179    /// Returns the path to the vcs root.
180    ///
181    /// The vcs root is used for properly handling non-persistent storage of
182    /// tests.
183    pub fn vcs_root(&self) -> Option<&Path> {
184        self.vcs.as_ref().map(Vcs::root)
185    }
186}
187
188/// A fully loaded project, this can be constructed from [`ShallowProject`],
189/// which can be used to discover project paths without loading any
190/// configuration or manifests.
191#[derive(Debug, Clone)]
192pub struct Project {
193    base: ShallowProject,
194    manifest: Option<PackageManifest>,
195    config: ProjectConfig,
196    unit_test_template: Option<String>,
197}
198
199impl Project {
200    /// Create a new empty project.
201    pub fn new<P: Into<PathBuf>>(root: P) -> Self {
202        Self {
203            base: ShallowProject {
204                root: root.into(),
205                vcs: None,
206            },
207            manifest: None,
208            config: ProjectConfig::default(),
209            unit_test_template: None,
210        }
211    }
212
213    /// Attach a version control system to this project.
214    pub fn with_vcs(mut self, vcs: Option<Vcs>) -> Self {
215        self.base.vcs = vcs;
216        self
217    }
218
219    /// Attach a parsed manifest to this project.
220    pub fn with_manifest(mut self, manifest: Option<PackageManifest>) -> Self {
221        self.manifest = manifest;
222        self
223    }
224
225    /// Attach a parsed project config to this project.
226    pub fn with_config(mut self, config: ProjectConfig) -> Self {
227        self.config = config;
228        self
229    }
230
231    /// Attach a unit test template to this project.
232    pub fn with_unit_test_template(mut self, unit_test_template: Option<String>) -> Self {
233        self.unit_test_template = unit_test_template;
234        self
235    }
236
237    /// Checks the given directory for a project root, returning `true` if it
238    /// was found.
239    pub fn exists_at(dir: &Path) -> io::Result<bool> {
240        if dir.join(MANIFEST_FILE).try_exists()? {
241            return Ok(true);
242        }
243
244        Ok(false)
245    }
246}
247
248impl Project {
249    /// Returns the shallow base object for this project.
250    pub fn base(&self) -> &ShallowProject {
251        &self.base
252    }
253
254    /// The fully parsed project manifest.
255    pub fn manifest(&self) -> Option<&PackageManifest> {
256        self.manifest.as_ref()
257    }
258
259    /// The fully parsed project config layer.
260    pub fn config(&self) -> &ProjectConfig {
261        &self.config
262    }
263
264    /// Returns the unit test template, that is, the source template to
265    /// use when generating new unit tests.
266    pub fn unit_test_template(&self) -> Option<&str> {
267        self.unit_test_template.as_deref()
268    }
269
270    /// Returns the [`Vcs`] this project is managed by or `None` if no supported
271    /// Vcs was found.
272    pub fn vcs(&self) -> Option<&Vcs> {
273        self.base.vcs.as_ref()
274    }
275}
276
277impl Project {
278    /// Returns the path to the test root. That is the path within the project
279    /// root where the test suite is located.
280    ///
281    /// The test root is used to resolve test identifiers.
282    pub fn unit_tests_root(&self) -> PathBuf {
283        self.root().join(&self.config.unit_tests_root)
284    }
285
286    /// Returns the path to the unit test template, that is, the source template to
287    /// use when generating new unit tests.
288    ///
289    /// See [`Project::template_root`] for reading the template.
290    pub fn template_root(&self) -> Option<&Path> {
291        self.manifest
292            .as_ref()
293            .and_then(|m| m.template.as_ref())
294            .map(|t| Path::new(t.path.as_str()))
295    }
296
297    /// Returns the path to the unit test template, that is, the source template to
298    /// use when generating new unit tests.
299    ///
300    /// See [`Project::template_root`] for reading the template.
301    pub fn unit_test_template_file(&self) -> PathBuf {
302        let mut dir = self.unit_tests_root();
303        dir.push("template.typ");
304        dir
305    }
306
307    /// Create a path to the test directory for the given identifier.
308    pub fn unit_test_dir(&self, id: &Id) -> PathBuf {
309        let mut dir = self.unit_tests_root();
310        dir.extend(id.components());
311        dir
312    }
313
314    /// Create a path to the test script for the given identifier.
315    pub fn unit_test_script(&self, id: &Id) -> PathBuf {
316        let mut dir = self.unit_test_dir(id);
317        dir.push("test.typ");
318        dir
319    }
320
321    /// Create a path to the reference script for the given identifier.
322    pub fn unit_test_ref_script(&self, id: &Id) -> PathBuf {
323        let mut dir = self.unit_test_dir(id);
324        dir.push("ref.typ");
325        dir
326    }
327
328    /// Create a path to the reference directory for the given identifier.
329    pub fn unit_test_ref_dir(&self, id: &Id) -> PathBuf {
330        let mut dir = self.unit_test_dir(id);
331        dir.push("ref");
332        dir
333    }
334
335    /// Create a path to the output directory for the given identifier.
336    pub fn unit_test_out_dir(&self, id: &Id) -> PathBuf {
337        let mut dir = self.unit_test_dir(id);
338        dir.push("out");
339        dir
340    }
341
342    /// Create a path to the difference directory for the given identifier.
343    pub fn unit_test_diff_dir(&self, id: &Id) -> PathBuf {
344        let mut dir = self.unit_test_dir(id);
345        dir.push("diff");
346        dir
347    }
348}
349
350impl Deref for Project {
351    type Target = ShallowProject;
352
353    fn deref(&self) -> &Self::Target {
354        self.base()
355    }
356}
357
358fn validate_manifest(manifest: &PackageManifest) -> Result<(), ValidationError> {
359    let PackageManifest {
360        package: _,
361        template,
362        tool: _,
363        unknown_fields: _,
364    } = manifest;
365
366    let Some(template) = template else {
367        return Ok(());
368    };
369
370    let mut error = ValidationError {
371        errors: BTreeMap::new(),
372    };
373
374    if !is_trivial_path(template.path.as_str()) {
375        error
376            .errors
377            .insert("template.path".into(), ValidationErrorCause::NonTrivialPath);
378    }
379
380    if !is_trivial_path(template.entrypoint.as_str()) {
381        error.errors.insert(
382            "template.entrypoint".into(),
383            ValidationErrorCause::NonTrivialPath,
384        );
385    }
386
387    if !error.errors.is_empty() {
388        return Err(error);
389    }
390
391    Ok(())
392}
393
394fn validate_config(config: &ProjectConfig) -> Result<(), ValidationError> {
395    let ProjectConfig {
396        unit_tests_root,
397        defaults: _,
398    } = config;
399
400    let mut error = ValidationError {
401        errors: BTreeMap::new(),
402    };
403
404    if !is_trivial_path(unit_tests_root.as_str()) {
405        error
406            .errors
407            .insert("tests".into(), ValidationErrorCause::NonTrivialPath);
408    }
409
410    if !error.errors.is_empty() {
411        return Err(error);
412    }
413
414    Ok(())
415}
416
417fn is_trivial_path<P: AsRef<Path>>(path: P) -> bool {
418    let path = path.as_ref();
419    path.is_relative() && path.components().all(|c| matches!(c, Component::Normal(_)))
420}
421
422/// Returned by [`ShallowProject::load`].
423#[derive(Debug, Error)]
424pub enum LoadError {
425    /// An error occurred while parsing the project manifest.
426    #[error("an error occurred while parsing the project manifest")]
427    Manifest(#[from] ManifestError),
428
429    /// An error occurred while parsing the project config.
430    #[error("an error occurred while parsing the project config")]
431    Config(#[from] ConfigError),
432
433    /// An io error occurred.
434    #[error("an io error occurred")]
435    Io(#[from] io::Error),
436}
437
438/// Contained in [`ConfigError`] and [`ManifestError`].
439#[derive(Debug, Error)]
440#[error("encountered {} errors while validating", errors.len())]
441pub struct ValidationError {
442    /// The inner errors for each field.
443    pub errors: BTreeMap<EcoString, ValidationErrorCause>,
444}
445
446/// The cause for a validation error of an individual field.
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
448pub enum ValidationErrorCause {
449    /// A path was not trivial when it must be, i.e. it contained components
450    /// such as `.` or `..`.
451    NonTrivialPath,
452}
453
454/// Returned by [`ShallowProject::parse_config`].
455#[derive(Debug, Error)]
456pub enum ConfigError {
457    /// An error occurred while validating the project config.
458    #[error("an error occurred while validating project config")]
459    Invalid(#[from] ValidationError),
460
461    /// An error occurred while parsing the project config.
462    #[error("an error occurred while parsing the project config")]
463    Parse(#[from] toml::de::Error),
464
465    /// An io error occurred.
466    #[error("an io error occurred")]
467    Io(#[from] io::Error),
468}
469
470/// Returned by [`ShallowProject::parse_manifest`].
471#[derive(Debug, Error)]
472pub enum ManifestError {
473    /// An error occurred while validating the project manifest.
474    #[error("an error occurred while validating project manifest")]
475    Invalid(#[from] ValidationError),
476
477    /// An error occurred while parsing the project manifest.
478    #[error("an error occurred while parsing the project manifest")]
479    Parse(#[from] toml::de::Error),
480
481    /// An io error occurred.
482    #[error("an io error occurred")]
483    Io(#[from] io::Error),
484}
485
486#[cfg(test)]
487mod tests {
488    use tytanic_utils::typst::{PackageManifestBuilder, TemplateInfoBuilder};
489
490    use super::*;
491
492    #[test]
493    fn test_unit_test_paths() {
494        let project = Project::new("root");
495        let id = Id::new("a/b").unwrap();
496
497        assert_eq!(
498            project.unit_tests_root(),
499            PathBuf::from_iter(["root", "tests"])
500        );
501        assert_eq!(
502            project.unit_test_dir(&id),
503            PathBuf::from_iter(["root", "tests", "a", "b"])
504        );
505        assert_eq!(
506            project.unit_test_script(&id),
507            PathBuf::from_iter(["root", "tests", "a", "b", "test.typ"])
508        );
509
510        let project = Project::new("root").with_config(ProjectConfig {
511            unit_tests_root: "foo".into(),
512            ..Default::default()
513        });
514
515        assert_eq!(
516            project.unit_test_ref_script(&id),
517            PathBuf::from_iter(["root", "foo", "a", "b", "ref.typ"])
518        );
519        assert_eq!(
520            project.unit_test_ref_dir(&id),
521            PathBuf::from_iter(["root", "foo", "a", "b", "ref"])
522        );
523        assert_eq!(
524            project.unit_test_out_dir(&id),
525            PathBuf::from_iter(["root", "foo", "a", "b", "out"])
526        );
527        assert_eq!(
528            project.unit_test_diff_dir(&id),
529            PathBuf::from_iter(["root", "foo", "a", "b", "diff"])
530        );
531    }
532
533    #[test]
534    fn test_validation_default() {
535        let config = ProjectConfig::default();
536        validate_config(&config).unwrap();
537    }
538
539    #[test]
540    fn test_validation_trivial_paths() {
541        let manifest = PackageManifestBuilder::new()
542            .template(
543                TemplateInfoBuilder::new()
544                    .path("foo")
545                    .entrypoint("bar.typ")
546                    .build(),
547            )
548            .build();
549
550        let config = ProjectConfig {
551            unit_tests_root: "qux".into(),
552            ..Default::default()
553        };
554
555        validate_manifest(&manifest).unwrap();
556        validate_config(&config).unwrap();
557    }
558
559    #[test]
560    fn test_validation_non_trival_paths() {
561        let manifest = PackageManifestBuilder::new()
562            .template(TemplateInfoBuilder::new().path("..").build())
563            .build();
564
565        let config = ProjectConfig {
566            unit_tests_root: "/.".into(),
567            ..Default::default()
568        };
569
570        let manifest = validate_manifest(&manifest).unwrap_err();
571        let config = validate_config(&config).unwrap_err();
572
573        assert_eq!(manifest.errors.len(), 1);
574        assert_eq!(config.errors.len(), 1);
575
576        assert_eq!(
577            manifest.errors.get("template.path").unwrap(),
578            &ValidationErrorCause::NonTrivialPath
579        );
580        assert_eq!(
581            config.errors.get("tests").unwrap(),
582            &ValidationErrorCause::NonTrivialPath
583        );
584    }
585}