1use 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
23pub const MANIFEST_FILE: &str = "typst.toml";
26
27#[derive(Debug, Clone)]
30pub struct ShallowProject {
31 root: PathBuf,
32 vcs: Option<Vcs>,
33}
34
35impl ShallowProject {
36 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 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 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 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 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 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 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 pub fn root(&self) -> &Path {
171 &self.root
172 }
173
174 pub fn manifest_file(&self) -> PathBuf {
176 self.root.join(MANIFEST_FILE)
177 }
178
179 pub fn vcs_root(&self) -> Option<&Path> {
184 self.vcs.as_ref().map(Vcs::root)
185 }
186}
187
188#[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 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 pub fn with_vcs(mut self, vcs: Option<Vcs>) -> Self {
215 self.base.vcs = vcs;
216 self
217 }
218
219 pub fn with_manifest(mut self, manifest: Option<PackageManifest>) -> Self {
221 self.manifest = manifest;
222 self
223 }
224
225 pub fn with_config(mut self, config: ProjectConfig) -> Self {
227 self.config = config;
228 self
229 }
230
231 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 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 pub fn base(&self) -> &ShallowProject {
251 &self.base
252 }
253
254 pub fn manifest(&self) -> Option<&PackageManifest> {
256 self.manifest.as_ref()
257 }
258
259 pub fn config(&self) -> &ProjectConfig {
261 &self.config
262 }
263
264 pub fn unit_test_template(&self) -> Option<&str> {
267 self.unit_test_template.as_deref()
268 }
269
270 pub fn vcs(&self) -> Option<&Vcs> {
273 self.base.vcs.as_ref()
274 }
275}
276
277impl Project {
278 pub fn unit_tests_root(&self) -> PathBuf {
283 self.root().join(&self.config.unit_tests_root)
284 }
285
286 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 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 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 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 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 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 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 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#[derive(Debug, Error)]
424pub enum LoadError {
425 #[error("an error occurred while parsing the project manifest")]
427 Manifest(#[from] ManifestError),
428
429 #[error("an error occurred while parsing the project config")]
431 Config(#[from] ConfigError),
432
433 #[error("an io error occurred")]
435 Io(#[from] io::Error),
436}
437
438#[derive(Debug, Error)]
440#[error("encountered {} errors while validating", errors.len())]
441pub struct ValidationError {
442 pub errors: BTreeMap<EcoString, ValidationErrorCause>,
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
448pub enum ValidationErrorCause {
449 NonTrivialPath,
452}
453
454#[derive(Debug, Error)]
456pub enum ConfigError {
457 #[error("an error occurred while validating project config")]
459 Invalid(#[from] ValidationError),
460
461 #[error("an error occurred while parsing the project config")]
463 Parse(#[from] toml::de::Error),
464
465 #[error("an io error occurred")]
467 Io(#[from] io::Error),
468}
469
470#[derive(Debug, Error)]
472pub enum ManifestError {
473 #[error("an error occurred while validating project manifest")]
475 Invalid(#[from] ValidationError),
476
477 #[error("an error occurred while parsing the project manifest")]
479 Parse(#[from] toml::de::Error),
480
481 #[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}