use std::env;
use std::error::Error;
use std::fs::File;
use std::io::{BufRead, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::LazyLock;
use std::{fs, process};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use gazetta_core::model::Source;
use gazetta_core::render::Gazetta;
use slug::slugify;
trait RenderPaths {
fn render_paths(&self, source_path: &Path, dest_path: &Path) -> Result<(), Box<dyn Error>>;
}
impl<G: Gazetta> RenderPaths for G {
fn render_paths(&self, source_path: &Path, dest_path: &Path) -> Result<(), Box<dyn Error>> {
let source = Source::new(source_path)?;
self.render(&source.index()?, dest_path)?;
Ok(())
}
}
pub fn run<G: Gazetta>(gazetta: G) -> ! {
process::exit(_run(&gazetta, None).unwrap_or_else(|e| {
eprintln!("{e}");
1
}))
}
pub fn run_with_version<G: Gazetta>(gazetta: G, version: &'static str) -> ! {
process::exit(_run(&gazetta, Some(version)).unwrap_or_else(|e| {
eprintln!("{e}");
1
}))
}
static CLI_NAME: LazyLock<String> = LazyLock::new(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "gazetta".into())
});
#[derive(Parser)]
#[command(long_about = None, name = &**CLI_NAME)]
pub struct Cli {
#[arg(short, long, value_name = "DIRECTORY")]
source: Option<PathBuf>,
#[command(subcommand)]
commands: Commands,
}
#[derive(Subcommand)]
enum Commands {
Render {
#[arg(short, long)]
force: bool,
destination: PathBuf,
},
New {
#[arg(short, long)]
edit: bool,
directory: PathBuf,
title: String,
},
Edit {
file: PathBuf,
},
}
fn current_date() -> String {
::chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn edit_file(path: &Path) -> Result<i32, Box<dyn Error>> {
let cwd = path
.parent()
.ok_or_else(|| format!("path is not a file: {}", path.display()))?;
let fname: &Path = path
.file_name()
.ok_or_else(|| format!("path is not a file: {}", path.display()))?
.as_ref();
match Command::new(
env::var_os("EDITOR")
.as_deref()
.unwrap_or_else(|| "vim".as_ref()),
)
.arg(fname)
.current_dir(cwd)
.status()
{
Ok(status) => match status.code() {
Some(code) => Ok(code),
None => Err("Editor was killed.".into()),
},
Err(e) => Err(format!("Failed to spawn editor: {e}").into()),
}
}
fn modify_updated(path: &Path) -> Result<(), Box<dyn Error>> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.read(true)
.create(false)
.truncate(false)
.open(path)?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
let mut reader = std::io::Cursor::new(contents);
let mut line = String::new();
if reader.read_line(&mut line)? == 0 {
return Ok(());
}
if line.trim_end() != "---" {
return Ok(());
}
let range = loop {
line.clear();
let line_start = reader.position();
if reader.read_line(&mut line)? == 0 {
return Err("unexpected end of file metadata".into());
}
if line.trim_end() == "---" {
break line_start..line_start;
}
if line.starts_with("updated:") {
break line_start..reader.position();
}
};
let contents = reader.into_inner();
let date = current_date();
file.seek(SeekFrom::Start(0))?;
file.set_len(0)?;
let mut writer = BufWriter::new(file);
writer.write_all(&contents[..range.start as usize])?;
writeln!(writer, "updated: {date}")?;
writer.write_all(&contents[range.end as usize..])?;
writer.flush()?;
Ok(())
}
fn _run(
render_paths: &dyn RenderPaths,
version: Option<&'static str>,
) -> Result<i32, Box<dyn Error>> {
let mut command = Cli::command();
if let Some(version) = version {
command = command.version(version);
}
let cli = Cli::from_arg_matches_mut(&mut command.get_matches())?;
let source_path = cli
.source
.or_else(|| {
let mut path = PathBuf::new();
path.push(".");
while path.exists() {
path.push("gazetta.yaml");
let is_root = path.exists();
path.pop();
if is_root {
return Some(path);
}
path.push("..");
}
None
})
.ok_or("Could not find a gazetta config in this directory or any parent directories.")?;
match cli.commands {
Commands::Render { force, destination } => {
if fs::metadata(&destination).is_ok() {
if force {
fs::remove_dir_all(&destination).map_err(|e| {
format!("Failed to remove '{}': {}", destination.display(), e)
})?;
} else {
return Err(format!("Target '{}' exists.", destination.display()).into());
}
}
render_paths.render_paths(&source_path, &destination)?;
Ok(0)
}
Commands::New {
edit,
directory,
title,
} => {
let mut path = directory;
path.push(slugify(&title));
if path.exists() {
return Err(format!("Directory '{}' exists.", path.display()).into());
}
fs::create_dir(&path)
.map_err(|e| format!("Failed to create directory '{}': {}", path.display(), e))?;
path.push("index.md");
let mut file = std::io::BufWriter::new(File::create(&path)?);
let date = current_date();
writeln!(file, "---")?;
writeln!(file, "title: {}", &title)?;
writeln!(file, "date: {date}")?;
writeln!(file, "updated: {date}")?;
writeln!(file, "---")?;
println!("Created page: {}", path.display());
file.flush()?;
drop(file);
if edit { edit_file(&path) } else { Ok(0) }
}
Commands::Edit { file } => match edit_file(&file)? {
0 => modify_updated(&file).map(|_| 0),
n => Ok(n),
},
}
}