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::{fs, process};
24
25use clap::{Parser, Subcommand};
26use gazetta_core::model::Source;
27use gazetta_core::render::Gazetta;
28use slug::slugify;
29
30// Internal trait to use dynamic dispatch instead of monomorphizing run.
31trait RenderPaths {
32    fn render_paths(&self, source_path: &Path, dest_path: &Path) -> Result<(), Box<dyn Error>>;
33}
34
35impl<G: Gazetta> RenderPaths for G {
36    fn render_paths(&self, source_path: &Path, dest_path: &Path) -> Result<(), Box<dyn Error>> {
37        let source = Source::new(source_path)?;
38        self.render(&source, dest_path)?;
39        Ok(())
40    }
41}
42
43/// Run the CLI.
44pub fn run<G: Gazetta>(gazetta: G) -> ! {
45    process::exit(_run(&gazetta).unwrap_or_else(|e| {
46        eprintln!("{}", e);
47        1
48    }))
49}
50
51#[derive(Parser)]
52#[command(version, about, long_about = None)]
53pub struct Cli {
54    /// Specify the source directory (defaults to the current directory)
55    #[arg(short, long, value_name = "DIRECTORY")]
56    source: Option<PathBuf>,
57    #[command(subcommand)]
58    commands: Commands,
59}
60
61#[derive(Subcommand)]
62enum Commands {
63    /// Render the gazetta site into the target directory.
64    Render {
65        /// Overwrite any existing
66        #[arg(short, long)]
67        force: bool,
68        /// The output directory
69        destination: PathBuf,
70    },
71    /// Create a new post in the target directory.
72    New {
73        /// Edit the new page in your $EDITOR
74        #[arg(short, long)]
75        edit: bool,
76        /// Directory in which to create the page
77        directory: PathBuf,
78        /// The page title.
79        title: String,
80    },
81    /// Edit a the target post.
82    Edit {
83        /// The file (page) to edit.
84        file: PathBuf,
85    },
86}
87
88fn current_date() -> String {
89    ::chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
90}
91
92fn edit_file(path: &Path) -> Result<i32, Box<dyn Error>> {
93    let cwd = path
94        .parent()
95        .ok_or_else(|| format!("path is not a file: {}", path.display()))?;
96    let fname: &Path = path
97        .file_name()
98        .ok_or_else(|| format!("path is not a file: {}", path.display()))?
99        .as_ref();
100    match Command::new(
101        env::var_os("EDITOR")
102            .as_deref()
103            .unwrap_or_else(|| "vim".as_ref()),
104    )
105    .arg(fname)
106    .current_dir(cwd)
107    .status()
108    {
109        Ok(status) => match status.code() {
110            Some(code) => Ok(code),
111            None => Err("Editor was killed.".into()),
112        },
113        Err(e) => Err(format!("Failed to spawn editor: {}", e).into()),
114    }
115}
116
117fn modify_updated(path: &Path) -> Result<(), Box<dyn Error>> {
118    let mut file = std::fs::OpenOptions::new()
119        .write(true)
120        .read(true)
121        .create(false)
122        .truncate(false)
123        .open(path)?;
124
125    // Read the file.
126    let mut contents = Vec::new();
127    file.read_to_end(&mut contents)?;
128
129    // Find the "updated: ..." metadata line.
130    let mut reader = std::io::Cursor::new(contents);
131    let mut line = String::new();
132
133    // If the file is empty, don't do anything.
134    if reader.read_line(&mut line)? == 0 {
135        return Ok(());
136    }
137    // If the file has no metadata, it isn't our problem.
138    if line.trim_end() != "---" {
139        return Ok(());
140    }
141    let range = loop {
142        line.clear();
143        let line_start = reader.position();
144        if reader.read_line(&mut line)? == 0 {
145            return Err("unexpected end of file metadata".into());
146        }
147        if line.trim_end() == "---" {
148            break line_start..line_start;
149        }
150        if line.starts_with("updated:") {
151            break line_start..reader.position();
152        }
153    };
154
155    // Replace it with the new date.
156    let contents = reader.into_inner();
157    let date = current_date();
158
159    file.seek(SeekFrom::Start(0))?;
160    file.set_len(0)?;
161
162    let mut writer = BufWriter::new(file);
163    writer.write_all(&contents[..range.start as usize])?;
164    writeln!(writer, "updated: {date}")?;
165    writer.write_all(&contents[range.end as usize..])?;
166    writer.flush()?;
167    Ok(())
168}
169
170fn _run(render_paths: &dyn RenderPaths) -> Result<i32, Box<dyn Error>> {
171    let cli = Cli::parse();
172    let source_path = cli
173        .source
174        .or_else(|| {
175            let mut path = PathBuf::new();
176            path.push(".");
177            while path.exists() {
178                path.push("gazetta.yaml");
179                let is_root = path.exists();
180                path.pop();
181                if is_root {
182                    return Some(path);
183                }
184                path.push("..");
185            }
186            None
187        })
188        .ok_or("Could not find a gazetta config in this directory or any parent directories.")?;
189
190    match cli.commands {
191        Commands::Render { force, destination } => {
192            if fs::metadata(&destination).is_ok() {
193                if force {
194                    fs::remove_dir_all(&destination).map_err(|e| {
195                        format!("Failed to remove '{}': {}", destination.display(), e)
196                    })?;
197                } else {
198                    return Err(format!("Target '{}' exists.", destination.display()).into());
199                }
200            }
201            render_paths.render_paths(&source_path, &destination)?;
202            Ok(0)
203        }
204        Commands::New {
205            edit,
206            directory,
207            title,
208        } => {
209            let mut path = directory;
210            path.push(slugify(&title));
211            if path.exists() {
212                return Err(format!("Directory '{}' exists.", path.display()).into());
213            }
214            fs::create_dir(&path)
215                .map_err(|e| format!("Failed to create directory '{}': {}", path.display(), e))?;
216            path.push("index.md");
217
218            let mut file = std::io::BufWriter::new(File::create(&path)?);
219            let date = current_date();
220
221            writeln!(file, "---")?;
222            writeln!(file, "title: {}", &title)?;
223            writeln!(file, "date: {}", date)?;
224            writeln!(file, "updated: {}", date)?;
225            writeln!(file, "---")?;
226            println!("Created page: {}", path.display());
227            file.flush()?;
228            drop(file);
229
230            if edit {
231                edit_file(&path)
232            } else {
233                Ok(0)
234            }
235        }
236        Commands::Edit { file } => match edit_file(&file)? {
237            0 => modify_updated(&file).map(|_| 0),
238            n => Ok(n),
239        },
240    }
241}