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 edit_file(path: &Path) -> Result<i32, Box<dyn Error>> {
89    let cwd = path
90        .parent()
91        .ok_or_else(|| format!("path is not a file: {}", path.display()))?;
92    let fname: &Path = path
93        .file_name()
94        .ok_or_else(|| format!("path is not a file: {}", path.display()))?
95        .as_ref();
96    match Command::new(
97        env::var_os("EDITOR")
98            .as_deref()
99            .unwrap_or_else(|| "vim".as_ref()),
100    )
101    .arg(fname)
102    .current_dir(cwd)
103    .status()
104    {
105        Ok(status) => match status.code() {
106            Some(code) => Ok(code),
107            None => Err("Editor was killed.".into()),
108        },
109        Err(e) => Err(format!("Failed to spawn editor: {}", e).into()),
110    }
111}
112
113fn modify_updated(path: &Path) -> Result<(), Box<dyn Error>> {
114    let mut file = std::fs::OpenOptions::new()
115        .write(true)
116        .read(true)
117        .create(false)
118        .truncate(false)
119        .open(path)?;
120
121    // Read the file.
122    let mut contents = Vec::new();
123    file.read_to_end(&mut contents)?;
124
125    // Find the "updated: ..." metadata line.
126    let mut reader = std::io::Cursor::new(contents);
127    let mut line = String::new();
128
129    // If the file is empty, don't do anything.
130    if reader.read_line(&mut line)? == 0 {
131        return Ok(());
132    }
133    // If the file has no metadata, it isn't our problem.
134    if line.trim_end() != "---" {
135        return Ok(());
136    }
137    let range = loop {
138        line.clear();
139        let line_start = reader.position();
140        if reader.read_line(&mut line)? == 0 {
141            return Err("unexpected end of file metadata".into());
142        }
143        if line.trim_end() == "---" {
144            break line_start..line_start;
145        }
146        if line.starts_with("updated:") {
147            break line_start..reader.position();
148        }
149    };
150
151    // Replace it with the new date.
152    let contents = reader.into_inner();
153    let date = ::chrono::Local::now().to_rfc3339();
154
155    file.seek(SeekFrom::Start(0))?;
156    file.set_len(0)?;
157
158    let mut writer = BufWriter::new(file);
159    writer.write_all(&contents[..range.start as usize])?;
160    writeln!(writer, "updated: {date}")?;
161    writer.write_all(&contents[range.end as usize..])?;
162    writer.flush()?;
163    Ok(())
164}
165
166fn _run(render_paths: &dyn RenderPaths) -> Result<i32, Box<dyn Error>> {
167    let cli = Cli::parse();
168    let source_path = cli
169        .source
170        .or_else(|| {
171            let mut path = PathBuf::new();
172            path.push(".");
173            while path.exists() {
174                path.push("gazetta.yaml");
175                let is_root = path.exists();
176                path.pop();
177                if is_root {
178                    return Some(path);
179                }
180                path.push("..");
181            }
182            None
183        })
184        .ok_or("Could not find a gazetta config in this directory or any parent directories.")?;
185
186    match cli.commands {
187        Commands::Render { force, destination } => {
188            if fs::metadata(&destination).is_ok() {
189                if force {
190                    fs::remove_dir_all(&destination).map_err(|e| {
191                        format!("Failed to remove '{}': {}", destination.display(), e)
192                    })?;
193                } else {
194                    return Err(format!("Target '{}' exists.", destination.display()).into());
195                }
196            }
197            render_paths.render_paths(&source_path, &destination)?;
198            Ok(0)
199        }
200        Commands::New {
201            edit,
202            directory,
203            title,
204        } => {
205            let mut path = directory;
206            path.push(slugify(&title));
207            if path.exists() {
208                return Err(format!("Directory '{}' exists.", path.display()).into());
209            }
210            fs::create_dir(&path)
211                .map_err(|e| format!("Failed to create directory '{}': {}", path.display(), e))?;
212            path.push("index.md");
213
214            let mut file = std::io::BufWriter::new(File::create(&path)?);
215            let date = ::chrono::Local::now().to_rfc3339();
216            writeln!(file, "---")?;
217            writeln!(file, "title: {}", &title)?;
218            writeln!(file, "date: {}", date)?;
219            writeln!(file, "updated: {}", date)?;
220            writeln!(file, "---")?;
221            println!("Created page: {}", path.display());
222            file.flush()?;
223            drop(file);
224
225            if edit {
226                edit_file(&path)
227            } else {
228                Ok(0)
229            }
230        }
231        Commands::Edit { file } => match edit_file(&file)? {
232            0 => modify_updated(&file).map(|_| 0),
233            n => Ok(n),
234        },
235    }
236}