bard/
project.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::iter;
4use std::process::Command;
5use std::process::Stdio;
6use std::str;
7
8use serde::de::Error as _;
9use serde::{Deserialize, Deserializer};
10
11use crate::app::App;
12use crate::book::{self, Book, Song, SongRef};
13use crate::default_project::DEFAULT_PROJECT;
14use crate::music::Notation;
15use crate::parser::Diagnostic;
16use crate::parser::Parser;
17use crate::parser::ParserConfig;
18use crate::prelude::*;
19use crate::render::tex_tools::TexConfig;
20use crate::render::tex_tools::TexTools;
21use crate::render::Renderer;
22use crate::util::ExitStatusExt;
23
24pub use toml::Value;
25
26mod input;
27use input::{InputSet, SongsGlobs};
28mod output;
29pub use output::{Format, Output};
30
31pub type Metadata = BTreeMap<Box<str>, Value>;
32
33type TomlMap = toml::map::Map<String, Value>;
34
35fn dir_songs() -> PathBuf {
36    "songs".into()
37}
38
39fn dir_templates() -> PathBuf {
40    "templates".into()
41}
42
43fn dir_output() -> PathBuf {
44    "output".into()
45}
46
47fn meta_default_chorus_label<'de, D>(de: D) -> Result<Metadata, D::Error>
48where
49    D: Deserializer<'de>,
50{
51    let mut meta = Metadata::deserialize(de)?;
52    if !meta.contains_key("chorus_label") {
53        meta.insert("chorus_label".into(), "Ch".into());
54    }
55    Ok(meta)
56}
57
58fn pathbuf_relative_only<'de, D>(de: D) -> Result<PathBuf, D::Error>
59where
60    D: Deserializer<'de>,
61{
62    let path = PathBuf::deserialize(de)?;
63    if !path.is_relative() {
64        let err = D::Error::custom(format!(
65            "Configured paths must be relative to the project directory. Path: {:?}",
66            path
67        ));
68        Err(err)
69    } else {
70        Ok(path)
71    }
72}
73
74fn default_smart_punctuation() -> bool {
75    true
76}
77
78#[derive(Deserialize, Debug)]
79pub struct Settings {
80    songs: SongsGlobs,
81
82    #[serde(default = "dir_songs", deserialize_with = "pathbuf_relative_only")]
83    dir_songs: PathBuf,
84    #[serde(default = "dir_templates", deserialize_with = "pathbuf_relative_only")]
85    dir_templates: PathBuf,
86    #[serde(default = "dir_output", deserialize_with = "pathbuf_relative_only")]
87    dir_output: PathBuf,
88
89    #[serde(default)]
90    pub notation: Notation,
91    #[serde(default = "default_smart_punctuation")]
92    pub smart_punctuation: bool,
93    tex: Option<TexConfig>,
94
95    pub output: Vec<Output>,
96    #[serde(deserialize_with = "meta_default_chorus_label")]
97    pub book: Metadata,
98}
99
100impl Settings {
101    pub fn version() -> u32 {
102        let major = env!("CARGO_PKG_VERSION_MAJOR");
103        major.parse().unwrap()
104    }
105
106    pub fn from_file(path: &Path, project_dir: &Path) -> Result<Settings> {
107        let contents = fs::read_to_string(path)
108            .with_context(|| format!("Failed to read project file {:?}", path))?;
109
110        let parse_err = || format!("Could not parse project file {:?}", path);
111
112        // Check version
113        let settings: TomlMap = toml::from_str(&contents).with_context(parse_err)?;
114        let version = settings.get("version").unwrap_or(&Value::Integer(1));
115        let version = version
116            .as_integer()
117            .ok_or_else(|| anyhow!("'version' field expected to be an interger"))
118            .with_context(parse_err)?;
119        let self_ver = Self::version();
120        if version < self_ver as _ {
121            bail!(
122                "This project was created with bard {}.x - to build with bard {}.x please follow the migration guide: https://bard.md/book/migration-{1}.html",
123                version, self_ver);
124        } else if version > self_ver as _ {
125            bail!("This project was created with a newer version {}.x of bard, the project cannot be built by bard {}.x", version, self_ver);
126        }
127
128        let mut settings: Settings = toml::from_str(&contents).with_context(parse_err)?;
129
130        settings.resolve(project_dir)?;
131        Ok(settings)
132    }
133
134    pub fn dir_songs(&self) -> &Path {
135        self.dir_songs.as_ref()
136    }
137
138    pub fn dir_output(&self) -> &Path {
139        self.dir_output.as_ref()
140    }
141
142    fn resolve(&mut self, project_dir: &Path) -> Result<()> {
143        self.dir_songs.resolve(project_dir);
144        self.dir_templates.resolve(project_dir);
145        self.dir_output.resolve(project_dir);
146
147        for output in self.output.iter_mut() {
148            output.resolve(&self.dir_templates, &self.dir_output)?;
149        }
150
151        Ok(())
152    }
153}
154
155#[cfg(unix)]
156static SCRIPT_EXT: &str = "sh";
157#[cfg(windows)]
158static SCRIPT_EXT: &str = "bat";
159
160#[derive(Debug)]
161pub struct Project {
162    pub project_dir: PathBuf,
163    pub settings: Settings,
164    pub book: Book,
165
166    project_file: PathBuf,
167    input_paths: Vec<PathBuf>,
168}
169
170impl Project {
171    pub fn new<P: AsRef<Path>>(app: &App, cwd: P) -> Result<Project> {
172        let cwd = cwd.as_ref();
173        let (project_file, project_dir) = Self::find_in_parents(cwd).ok_or_else(|| {
174            anyhow!(
175                "Could not find bard.toml file in current or parent directories\nCurrent directory: {:?}",
176                cwd,
177            )
178        })?;
179
180        app.status("Loading", format!("project at {:?}", project_dir));
181
182        let settings = Settings::from_file(&project_file, &project_dir)?;
183        let book = Book::new(&settings);
184
185        let mut project = Project {
186            project_file,
187            project_dir,
188            settings,
189            input_paths: vec![],
190            book,
191        };
192
193        project
194            .load_md_files(app)
195            .context("Failed to load input files")?;
196
197        Ok(project)
198    }
199
200    fn find_in_parents(start_dir: &Path) -> Option<(PathBuf, PathBuf)> {
201        assert!(start_dir.is_dir());
202
203        let mut parent = start_dir;
204        loop {
205            let project_file = parent.join("bard.toml");
206            if project_file.exists() {
207                return Some((project_file, parent.into()));
208            }
209
210            parent = parent.parent()?;
211        }
212    }
213
214    fn load_md_files(&mut self, app: &App) -> Result<()> {
215        let input_set = InputSet::new(&self.settings.dir_songs)?;
216        self.input_paths = self
217            .settings
218            .songs
219            .iter()
220            .try_fold(input_set, InputSet::apply_glob)?
221            .finalize()?;
222
223        let diag_sink = move |diag: Diagnostic| {
224            app.parser_diag(diag);
225        };
226
227        for path in self.input_paths.iter() {
228            app.check_interrupted()?;
229            let source = fs::read_to_string(path)?;
230            let config = ParserConfig::new(self.settings.notation, self.settings.smart_punctuation);
231            let rel_path = path.strip_prefix(&self.project_dir).unwrap_or(path);
232            let mut parser = Parser::new(&source, rel_path, config, diag_sink);
233            let songs = parser
234                .parse()
235                .map_err(|_| anyhow!("Could not parse file {:?}", path))?;
236            self.book.add_songs(songs);
237        }
238
239        self.book
240            .postprocess(&self.settings.dir_output, app.img_cache())?;
241
242        Ok(())
243    }
244
245    pub fn init<P: AsRef<Path>>(project_dir: P) -> Result<()> {
246        DEFAULT_PROJECT.resolve(project_dir.as_ref()).create()
247    }
248
249    pub fn book_section(&self) -> &Metadata {
250        &self.settings.book
251    }
252
253    pub fn songs(&self) -> &[Song] {
254        &self.book.songs
255    }
256
257    pub fn songs_sorted(&self) -> &[SongRef] {
258        &self.book.songs_sorted
259    }
260
261    fn run_script(&self, app: &App, output: &Output) -> Result<()> {
262        let script_fn = match output.script.as_deref() {
263            Some(s) => format!("{}.{}", s, SCRIPT_EXT),
264            None => return Ok(()),
265        };
266
267        let script_path = self.settings.dir_output().join(&script_fn);
268        if !script_path.exists() {
269            bail!(
270                "Could not find script file '{}' in the output directory.",
271                script_fn
272            );
273        }
274
275        app.status("Running", format!("script '{}'", script_fn));
276        let mut child = Command::new(script_path)
277            .current_dir(self.settings.dir_output())
278            .stdin(Stdio::null())
279            .stdout(Stdio::inherit())
280            .stderr(Stdio::inherit())
281            .env("BARD", app.bard_exe())
282            .env("OUTPUT", output.file.as_os_str())
283            .env("OUTPUT_STEM", output.file.file_stem().unwrap()) // NB. unwrap is fine here, there's always a stem
284            .env("PROJECT_DIR", self.project_dir.as_os_str())
285            .env("OUTPUT_DIR", self.settings.dir_output().as_os_str())
286            .spawn()?;
287        app.child_wait(&mut child)?.into_result()?;
288
289        Ok(())
290    }
291
292    pub fn render(&self, app: &App) -> Result<()> {
293        fs::create_dir_all(&self.settings.dir_output)?;
294
295        if self.settings.output.iter().any(|o| o.is_pdf()) {
296            // Initialize Tex tools ahead of actual rendering so that
297            // errors are reported early...
298            TexTools::initialize(app, self.settings.tex.as_ref())
299                .context("Could not initialize TeX tools.")?;
300        }
301
302        self.settings.output.iter().try_for_each(|output| {
303            app.check_interrupted()?;
304            app.status("Rendering", output.output_filename());
305            let context = || {
306                format!(
307                    "Could not render output file {:?}",
308                    output.file.file_name().unwrap()
309                )
310            };
311
312            let renderer = Renderer::new(self, output, app.img_cache()).with_context(context)?;
313            let tpl_version = renderer.version();
314
315            let res = renderer.render(app).with_context(context).and_then(|_| {
316                if app.post_process() {
317                    self.run_script(app, output).with_context(|| {
318                        format!(
319                            "Could not run script for output file {:?}",
320                            output.file.file_name().unwrap()
321                        )
322                    })
323                } else {
324                    Ok(())
325                }
326            });
327
328            // Perform version check of the template (if the Render supports it and there is a template file).
329            // This is done after rendering and preprocessing so that the CLI messages are at the bottom of the log.
330            // Otherwise they tend to be far behind eg. TeX output etc.
331            if let Some((tpl_version, tpl_path)) = tpl_version.zip(output.template.as_ref()) {
332                book::version::compat_check(app, tpl_path, &tpl_version);
333            }
334
335            res
336        })
337    }
338
339    pub fn input_paths(&self) -> &Vec<PathBuf> {
340        &self.input_paths
341    }
342
343    pub fn output_paths(&self) -> impl Iterator<Item = &Path> {
344        self.settings.output.iter().map(|o| o.file.as_path())
345    }
346
347    pub fn watch_paths(&self) -> impl Iterator<Item = &Path> {
348        // Input MD files:
349        // TODO: this won't work for wildcards
350        let inputs = self.input_paths.iter().map(PathBuf::as_ref);
351
352        // Templates:
353        let templates = self
354            .settings
355            .output
356            .iter()
357            .filter_map(Output::template_path);
358
359        // Images:
360        let images = self.book.iter_images().map(|i| i.full_path());
361
362        // bard.toml:
363        iter::once(self.project_file.as_path())
364            .chain(inputs)
365            .chain(templates)
366            .chain(images)
367    }
368}