gazetta_cli/
lib.rs

1//  Copyright (C) 2015 Steven Allen
2//
3//  This file is part of gazetta.
4//
5//  This program is free software: you can redistribute it and/or modify it under the terms of the
6//  GNU General Public License as published by the Free Software Foundation version 3 of the
7//  License.
8//
9//  This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
10//  without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See
11//  the GNU General Public License for more details.
12//
13//  You should have received a copy of the GNU General Public License along with this program.  If
14//  not, see <http://www.gnu.org/licenses/>.
15//
16
17use std::env;
18use std::error::Error;
19use std::fs::File;
20use std::io::{BufRead, BufWriter, Read, Seek, SeekFrom, Write};
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use std::sync::LazyLock;
24use std::{fs, process};
25
26use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
27use gazetta_core::model::Source;
28use gazetta_core::render::Gazetta;
29use slug::slugify;
30
31// Internal trait to use dynamic dispatch instead of monomorphizing run.
32trait RenderPaths {
33    fn render_paths(&self, source_path: &Path, dest_path: &Path) -> Result<(), Box<dyn Error>>;
34}
35
36impl<G: Gazetta> RenderPaths for G {
37    fn render_paths(&self, source_path: &Path, dest_path: &Path) -> Result<(), Box<dyn Error>> {
38        let source = Source::new(source_path)?;
39        self.render(&source.index()?, dest_path)?;
40        Ok(())
41    }
42}
43
44/// Run the CLI. Prefer [`run_with_version`] whenever possible.
45pub fn run<G: Gazetta>(gazetta: G) -> ! {
46    process::exit(_run(&gazetta, None).unwrap_or_else(|e| {
47        eprintln!("{e}");
48        1
49    }))
50}
51
52/// Run the CLI, specifying the CLI version.
53pub fn run_with_version<G: Gazetta>(gazetta: G, version: &'static str) -> ! {
54    process::exit(_run(&gazetta, Some(version)).unwrap_or_else(|e| {
55        eprintln!("{e}");
56        1
57    }))
58}
59
60static CLI_NAME: LazyLock<String> = LazyLock::new(|| {
61    std::env::current_exe()
62        .ok()
63        .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
64        .unwrap_or_else(|| "gazetta".into())
65});
66
67/// A fast static site generator written in Rust.
68#[derive(Parser)]
69#[command(long_about = None, name = &**CLI_NAME)]
70pub struct Cli {
71    /// Specify the source directory (defaults to the current directory)
72    #[arg(short, long, value_name = "DIRECTORY")]
73    source: Option<PathBuf>,
74    #[command(subcommand)]
75    commands: Commands,
76}
77
78#[derive(Subcommand)]
79enum Commands {
80    /// Render the gazetta site into the target directory.
81    Render {
82        /// Overwrite any existing
83        #[arg(short, long)]
84        force: bool,
85        /// The output directory
86        destination: PathBuf,
87    },
88    /// Create a new post in the target directory.
89    New {
90        /// Edit the new page in your $EDITOR
91        #[arg(short, long)]
92        edit: bool,
93        /// Directory in which to create the page
94        directory: PathBuf,
95        /// The page title.
96        title: String,
97    },
98    /// Edit a the target post.
99    Edit {
100        /// The file (page) to edit.
101        file: PathBuf,
102    },
103}
104
105fn current_date() -> String {
106    ::chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
107}
108
109fn edit_file(path: &Path) -> Result<i32, Box<dyn Error>> {
110    let cwd = path
111        .parent()
112        .ok_or_else(|| format!("path is not a file: {}", path.display()))?;
113    let fname: &Path = path
114        .file_name()
115        .ok_or_else(|| format!("path is not a file: {}", path.display()))?
116        .as_ref();
117    match Command::new(
118        env::var_os("EDITOR")
119            .as_deref()
120            .unwrap_or_else(|| "vim".as_ref()),
121    )
122    .arg(fname)
123    .current_dir(cwd)
124    .status()
125    {
126        Ok(status) => match status.code() {
127            Some(code) => Ok(code),
128            None => Err("Editor was killed.".into()),
129        },
130        Err(e) => Err(format!("Failed to spawn editor: {e}").into()),
131    }
132}
133
134fn modify_updated(path: &Path) -> Result<(), Box<dyn Error>> {
135    let mut file = std::fs::OpenOptions::new()
136        .write(true)
137        .read(true)
138        .create(false)
139        .truncate(false)
140        .open(path)?;
141
142    // Read the file.
143    let mut contents = Vec::new();
144    file.read_to_end(&mut contents)?;
145
146    // Find the "updated: ..." metadata line.
147    let mut reader = std::io::Cursor::new(contents);
148    let mut line = String::new();
149
150    // If the file is empty, don't do anything.
151    if reader.read_line(&mut line)? == 0 {
152        return Ok(());
153    }
154    // If the file has no metadata, it isn't our problem.
155    if line.trim_end() != "---" {
156        return Ok(());
157    }
158    let range = loop {
159        line.clear();
160        let line_start = reader.position();
161        if reader.read_line(&mut line)? == 0 {
162            return Err("unexpected end of file metadata".into());
163        }
164        if line.trim_end() == "---" {
165            break line_start..line_start;
166        }
167        if line.starts_with("updated:") {
168            break line_start..reader.position();
169        }
170    };
171
172    // Replace it with the new date.
173    let contents = reader.into_inner();
174    let date = current_date();
175
176    file.seek(SeekFrom::Start(0))?;
177    file.set_len(0)?;
178
179    let mut writer = BufWriter::new(file);
180    writer.write_all(&contents[..range.start as usize])?;
181    writeln!(writer, "updated: {date}")?;
182    writer.write_all(&contents[range.end as usize..])?;
183    writer.flush()?;
184    Ok(())
185}
186
187fn _run(
188    render_paths: &dyn RenderPaths,
189    version: Option<&'static str>,
190) -> Result<i32, Box<dyn Error>> {
191    let mut command = Cli::command();
192    if let Some(version) = version {
193        command = command.version(version);
194    }
195    let cli = Cli::from_arg_matches_mut(&mut command.get_matches())?;
196
197    let source_path = cli
198        .source
199        .or_else(|| {
200            let mut path = PathBuf::new();
201            path.push(".");
202            while path.exists() {
203                path.push("gazetta.yaml");
204                let is_root = path.exists();
205                path.pop();
206                if is_root {
207                    return Some(path);
208                }
209                path.push("..");
210            }
211            None
212        })
213        .ok_or("Could not find a gazetta config in this directory or any parent directories.")?;
214
215    match cli.commands {
216        Commands::Render { force, destination } => {
217            if fs::metadata(&destination).is_ok() {
218                if force {
219                    fs::remove_dir_all(&destination).map_err(|e| {
220                        format!("Failed to remove '{}': {}", destination.display(), e)
221                    })?;
222                } else {
223                    return Err(format!("Target '{}' exists.", destination.display()).into());
224                }
225            }
226            render_paths.render_paths(&source_path, &destination)?;
227            Ok(0)
228        }
229        Commands::New {
230            edit,
231            directory,
232            title,
233        } => {
234            let mut path = directory;
235            path.push(slugify(&title));
236            if path.exists() {
237                return Err(format!("Directory '{}' exists.", path.display()).into());
238            }
239            fs::create_dir(&path)
240                .map_err(|e| format!("Failed to create directory '{}': {}", path.display(), e))?;
241            path.push("index.md");
242
243            let mut file = std::io::BufWriter::new(File::create(&path)?);
244            let date = current_date();
245
246            writeln!(file, "---")?;
247            writeln!(file, "title: {}", &title)?;
248            writeln!(file, "date: {date}")?;
249            writeln!(file, "updated: {date}")?;
250            writeln!(file, "---")?;
251            println!("Created page: {}", path.display());
252            file.flush()?;
253            drop(file);
254
255            if edit { edit_file(&path) } else { Ok(0) }
256        }
257        Commands::Edit { file } => match edit_file(&file)? {
258            0 => modify_updated(&file).map(|_| 0),
259            n => Ok(n),
260        },
261    }
262}