learn and share notes on atproto (wip) 🦉
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1use clap::{Parser, Subcommand};
2use std::fs;
3use std::path::Path;
4use tokio_postgres::NoTls;
5
6#[derive(Parser)]
7#[command(name = "malfestio")]
8#[command(author = "Author <author@example.com>")]
9#[command(version = "0.1.0")]
10#[command(about = "Malfestio CLI", long_about = None)]
11struct Cli {
12 #[command(subcommand)]
13 command: Commands,
14}
15
16#[derive(Subcommand)]
17enum Commands {
18 /// Start the backend server
19 Start,
20 /// Run database migrations
21 Migrate {
22 /// Database URL (defaults to DB_URL env var)
23 #[arg(long)]
24 db_url: Option<String>,
25 },
26 /// Check OAuth flow and database state for a Bluesky handle
27 Check {
28 /// Bluesky handle to test (e.g., alice.bsky.social)
29 handle: String,
30 },
31 #[cfg(debug_assertions)]
32 /// [DEBUG ONLY] Debug utilities
33 Debug {
34 #[command(subcommand)]
35 command: DebugCommands,
36 },
37}
38
39#[cfg(debug_assertions)]
40#[derive(Subcommand)]
41enum DebugCommands {
42 /// Test article extraction and markdown conversion
43 Article {
44 /// Article URL to extract
45 url: String,
46 /// Save to file instead of printing to terminal
47 #[arg(short, long)]
48 output: Option<String>,
49 },
50}
51
52#[tokio::main]
53async fn main() -> malfestio_core::Result<()> {
54 let _ = dotenvy::from_filename(".env.local");
55 let _ = dotenvy::dotenv();
56
57 let cli = Cli::parse();
58
59 match &cli.command {
60 Commands::Start => {
61 malfestio_server::start().await?;
62 }
63 Commands::Migrate { db_url } => {
64 run_migrations(db_url.as_deref()).await?;
65 }
66 Commands::Check { handle } => {
67 check_flow(handle).await?;
68 }
69 #[cfg(debug_assertions)]
70 Commands::Debug { command } => match command {
71 DebugCommands::Article { url, output } => {
72 debug_article(url, output.as_deref()).await?;
73 }
74 },
75 }
76
77 Ok(())
78}
79
80async fn run_migrations(db_url: Option<&str>) -> malfestio_core::Result<()> {
81 let db_url = db_url
82 .map(String::from)
83 .or_else(|| std::env::var("DB_URL").ok())
84 .ok_or_else(|| {
85 malfestio_core::Error::InvalidArgument("DB_URL not provided via --db-url or DB_URL env var".to_string())
86 })?;
87
88 println!("Connecting to database...");
89 let (mut client, connection) = tokio_postgres::connect(&db_url, NoTls)
90 .await
91 .map_err(|e| malfestio_core::Error::Database(format!("Failed to connect to database: {}", e)))?;
92
93 tokio::spawn(async move {
94 if let Err(e) = connection.await {
95 eprintln!("Database connection error: {}", e);
96 }
97 });
98
99 println!("Connected to database");
100
101 client
102 .execute(
103 "CREATE TABLE IF NOT EXISTS schema_migrations (
104 id SERIAL PRIMARY KEY,
105 version TEXT NOT NULL UNIQUE,
106 applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
107 )",
108 &[],
109 )
110 .await
111 .map_err(|e| malfestio_core::Error::Database(format!("Failed to create migrations table: {}", e)))?;
112
113 let migrations_dir = Path::new("migrations");
114 if !migrations_dir.exists() {
115 return Err(malfestio_core::Error::InvalidArgument(
116 "migrations directory not found".to_string(),
117 ));
118 }
119
120 let mut entries: Vec<_> = fs::read_dir(migrations_dir)
121 .map_err(|e| malfestio_core::Error::Other(format!("Failed to read migrations directory: {}", e)))?
122 .filter_map(|e| e.ok())
123 .filter(|e| {
124 e.path()
125 .extension()
126 .and_then(|s| s.to_str())
127 .map(|s| s == "sql")
128 .unwrap_or(false)
129 })
130 .collect();
131
132 entries.sort_by_key(|e| e.file_name());
133
134 println!("Found {} migration files", entries.len());
135
136 for entry in entries {
137 let path = entry.path();
138 let filename = path.file_name().unwrap().to_str().unwrap();
139 let version = filename.trim_end_matches(".sql");
140
141 let row = client
142 .query_opt("SELECT version FROM schema_migrations WHERE version = $1", &[&version])
143 .await
144 .map_err(|e| malfestio_core::Error::Database(format!("Failed to check migration status: {}", e)))?;
145
146 if row.is_some() {
147 println!("Skipping {}: already applied", filename);
148 continue;
149 }
150
151 println!("Applying {}...", filename);
152
153 let sql = fs::read_to_string(&path)
154 .map_err(|e| malfestio_core::Error::Other(format!("Failed to read migration file: {}", e)))?;
155
156 let tx = client
157 .transaction()
158 .await
159 .map_err(|e| malfestio_core::Error::Database(format!("Failed to start transaction: {}", e)))?;
160
161 tx.batch_execute(&sql)
162 .await
163 .map_err(|e| malfestio_core::Error::Database(format!("Failed to execute migration {}: {}", filename, e)))?;
164
165 tx.execute("INSERT INTO schema_migrations (version) VALUES ($1)", &[&version])
166 .await
167 .map_err(|e| malfestio_core::Error::Database(format!("Failed to record migration: {}", e)))?;
168
169 tx.commit()
170 .await
171 .map_err(|e| malfestio_core::Error::Database(format!("Failed to commit migration: {}", e)))?;
172
173 println!("Applied {}", filename);
174 }
175
176 println!("All migrations complete!");
177
178 Ok(())
179}
180
181async fn check_flow(handle: &str) -> malfestio_core::Result<()> {
182 println!("Checking OAuth flow for {}...\n", handle);
183
184 // Get database URL
185 let db_url = std::env::var("DB_URL")
186 .or_else(|_| std::env::var("DATABASE_URL"))
187 .map_err(|_| malfestio_core::Error::InvalidArgument("DB_URL or DATABASE_URL not set".to_string()))?;
188
189 // Test database connection
190 print!("• Testing database connection... ");
191 let (client, connection) = tokio_postgres::connect(&db_url, NoTls)
192 .await
193 .map_err(|e| malfestio_core::Error::Database(format!("Failed to connect: {}", e)))?;
194
195 tokio::spawn(async move {
196 if let Err(e) = connection.await {
197 eprintln!("Database connection error: {}", e);
198 }
199 });
200
201 println!("✓ Connected");
202
203 let resolver = malfestio_server::oauth::resolver::IdentityResolver::new();
204
205 print!("• Resolving handle to DID... ");
206 let did = match resolver.resolve_handle(handle).await {
207 Ok(did) => {
208 println!("✓ {}", did);
209 did
210 }
211 Err(e) => {
212 println!("✗ Failed: {}", e);
213 return Err(malfestio_core::Error::Other(format!("Handle resolution failed: {}", e)));
214 }
215 };
216
217 print!("• Resolving DID to PDS... ");
218 let _resolved = match resolver.resolve_did(&did).await {
219 Ok(resolved) => {
220 println!("✓ {}", resolved.pds_url);
221 resolved
222 }
223 Err(e) => {
224 println!("✗ Failed: {}", e);
225 return Err(malfestio_core::Error::Other(format!("DID resolution failed: {}", e)));
226 }
227 };
228
229 print!("• Checking OAuth tokens... ");
230 let token_row = client
231 .query_opt(
232 "SELECT did, pds_url, created_at, updated_at FROM oauth_tokens WHERE did = $1",
233 &[&did],
234 )
235 .await
236 .map_err(|e| malfestio_core::Error::Database(format!("Token query failed: {}", e)))?;
237
238 if let Some(row) = token_row {
239 let updated_at: chrono::DateTime<chrono::Utc> = row.get(3);
240 println!("✓ Found (last updated: {})", updated_at.format("%Y-%m-%d %H:%M:%S UTC"));
241 } else {
242 println!("✗ Not found");
243 println!("\nℹ No OAuth tokens stored yet. Complete OAuth login first:");
244 println!(" 1. Start server: just start");
245 println!(" 2. Start frontend: just web-dev");
246 println!(" 3. Navigate to http://localhost:3000/login");
247 println!(" 4. Enter handle: {}", handle);
248 return Ok(());
249 }
250
251 print!("• Checking indexed decks... ");
252 let deck_rows = client
253 .query(
254 "SELECT at_uri, title, indexed_at FROM indexed_decks WHERE did = $1 ORDER BY indexed_at DESC LIMIT 5",
255 &[&did],
256 )
257 .await
258 .map_err(|e| malfestio_core::Error::Database(format!("Deck query failed: {}", e)))?;
259
260 if deck_rows.is_empty() {
261 println!("0 decks");
262 } else {
263 println!("{} deck(s)", deck_rows.len());
264 for row in &deck_rows {
265 let at_uri: String = row.get(0);
266 let title: Option<String> = row.get(1);
267 let indexed_at: chrono::DateTime<chrono::Utc> = row.get(2);
268 let time_ago = format_time_ago(indexed_at);
269 println!(" - {} ({})", title.unwrap_or_else(|| "Untitled".to_string()), time_ago);
270 println!(" {}", at_uri);
271 }
272 }
273
274 print!("• Checking indexed cards... ");
275 let card_count: i64 = client
276 .query_one("SELECT COUNT(*) FROM indexed_cards WHERE did = $1", &[&did])
277 .await
278 .map_err(|e| malfestio_core::Error::Database(format!("Card count query failed: {}", e)))?
279 .get(0);
280
281 println!("{} card(s)", card_count);
282
283 print!("• Checking indexed notes... ");
284 let note_count: i64 = client
285 .query_one("SELECT COUNT(*) FROM indexed_notes WHERE did = $1", &[&did])
286 .await
287 .map_err(|e| malfestio_core::Error::Database(format!("Note count query failed: {}", e)))?
288 .get(0);
289
290 println!("{} note(s)", note_count);
291
292 println!("\n✓ Status: Ready for testing");
293 println!("\nNext steps:");
294 println!(" - Publish content via UI to see it indexed");
295 println!(" - Check Bluesky profile: https://bsky.app/profile/{}", handle);
296 println!(" - Inspect records: https://pdsls.dev/at/{}", did);
297
298 Ok(())
299}
300
301fn format_time_ago(timestamp: chrono::DateTime<chrono::Utc>) -> String {
302 let now = chrono::Utc::now();
303 let duration = now.signed_duration_since(timestamp);
304
305 if duration.num_seconds() < 60 {
306 format!("{} seconds ago", duration.num_seconds())
307 } else if duration.num_minutes() < 60 {
308 format!("{} minutes ago", duration.num_minutes())
309 } else if duration.num_hours() < 24 {
310 format!("{} hours ago", duration.num_hours())
311 } else if duration.num_days() < 30 {
312 format!("{} days ago", duration.num_days())
313 } else {
314 format!("{} months ago", duration.num_days() / 30)
315 }
316}
317
318#[cfg(debug_assertions)]
319async fn debug_article(url: &str, output_file: Option<&str>) -> malfestio_core::Result<()> {
320 use malfestio_readability::Readability;
321
322 println!("Fetching article from: {}", url);
323
324 // Fetch HTML content with user-agent
325 let client = reqwest::Client::builder()
326 .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
327 .build()
328 .map_err(|e| malfestio_core::Error::Other(format!("Failed to build client: {}", e)))?;
329
330 let response = client
331 .get(url)
332 .send()
333 .await
334 .map_err(|e| malfestio_core::Error::Other(format!("Failed to fetch URL: {}", e)))?;
335
336 let html_content = response
337 .text()
338 .await
339 .map_err(|e| malfestio_core::Error::Other(format!("Failed to read response: {}", e)))?;
340
341 println!("Fetched {} bytes of HTML", html_content.len());
342
343 // Extract article using malfestio-readability
344 println!("Extracting article content...");
345 let url_clone = url.to_string();
346 let result = tokio::task::spawn_blocking(move || -> Result<malfestio_readability::Article, String> {
347 let readability = Readability::new(html_content, Some(&url_clone));
348 readability.parse().map_err(|e| format!("Parse error: {}", e))
349 })
350 .await
351 .map_err(|e| malfestio_core::Error::Other(format!("Task join error: {}", e)))?
352 .map_err(malfestio_core::Error::Other)?;
353
354 let article = result;
355
356 println!("✓ Extracted article:");
357 println!(" Title: {}", article.title);
358 if let Some(ref author) = article.author {
359 println!(" Author: {}", author);
360 }
361 if let Some(ref date) = article.published_date {
362 println!(" Published: {}", date);
363 }
364 println!(" Content length: {} bytes", article.content.len());
365 println!(" Markdown length: {} bytes", article.markdown.len());
366
367 if let Some(file_path) = output_file {
368 println!("\nSaving to file: {}", file_path);
369
370 let mut output = String::new();
371 output.push_str(&format!("# {}\n\n", article.title));
372 if let Some(ref author) = article.author {
373 output.push_str(&format!("**Author:** {}\n", author));
374 }
375 if let Some(ref date) = article.published_date {
376 output.push_str(&format!("**Published:** {}\n", date));
377 }
378 output.push_str(&format!("**Source:** {}\n\n", url));
379 output.push_str("---\n\n");
380 output.push_str(&article.markdown);
381
382 fs::write(file_path, output)
383 .map_err(|e| malfestio_core::Error::Other(format!("Failed to write file: {}", e)))?;
384
385 println!("✓ Saved to {}", file_path);
386 } else {
387 println!("\n{}", "=".repeat(80));
388 println!("# {}", article.title);
389 if let Some(ref author) = article.author {
390 println!("\n**Author:** {}", author);
391 }
392 if let Some(ref date) = article.published_date {
393 println!("**Published:** {}", date);
394 }
395 println!("**Source:** {}", url);
396 println!("{}", "=".repeat(80));
397 println!("\n{}", article.markdown);
398 }
399
400 Ok(())
401}