1use 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
31trait 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
44pub fn run<G: Gazetta>(gazetta: G) -> ! {
46 process::exit(_run(&gazetta, None).unwrap_or_else(|e| {
47 eprintln!("{e}");
48 1
49 }))
50}
51
52pub 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#[derive(Parser)]
69#[command(long_about = None, name = &**CLI_NAME)]
70pub struct Cli {
71 #[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 {
82 #[arg(short, long)]
84 force: bool,
85 destination: PathBuf,
87 },
88 New {
90 #[arg(short, long)]
92 edit: bool,
93 directory: PathBuf,
95 title: String,
97 },
98 Edit {
100 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 let mut contents = Vec::new();
144 file.read_to_end(&mut contents)?;
145
146 let mut reader = std::io::Cursor::new(contents);
148 let mut line = String::new();
149
150 if reader.read_line(&mut line)? == 0 {
152 return Ok(());
153 }
154 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 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}