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 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 let mut contents = Vec::new();
123 file.read_to_end(&mut contents)?;
124
125 let mut reader = std::io::Cursor::new(contents);
127 let mut line = String::new();
128
129 if reader.read_line(&mut line)? == 0 {
131 return Ok(());
132 }
133 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 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}