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::{fs, process};
24
25use clap::{Parser, Subcommand};
26use gazetta_core::model::Source;
27use gazetta_core::render::Gazetta;
28use slug::slugify;
29
30trait 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
43pub 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 #[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 {
65 #[arg(short, long)]
67 force: bool,
68 destination: PathBuf,
70 },
71 New {
73 #[arg(short, long)]
75 edit: bool,
76 directory: PathBuf,
78 title: String,
80 },
81 Edit {
83 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 let mut contents = Vec::new();
127 file.read_to_end(&mut contents)?;
128
129 let mut reader = std::io::Cursor::new(contents);
131 let mut line = String::new();
132
133 if reader.read_line(&mut line)? == 0 {
135 return Ok(());
136 }
137 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 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}