1use crate::api::DiscourseClient;
2use crate::api::PostEditOptions;
3use crate::api::TopicResponse;
4use crate::cli::ListFormat;
5use crate::commands::common::{emit_result, ensure_api_credentials, select_discourse};
6use crate::config::Config;
7use crate::utils::{
8 current_utc_iso8601, read_markdown, resolve_topic_path, slugify, strip_frontmatter,
9 write_markdown, yaml_scalar,
10};
11use anyhow::{Context, Result, anyhow};
12use serde_json::json;
13use std::fs;
14use std::io::{self, Read, Write};
15use std::path::Path;
16
17pub fn topic_pull(
18 config: &Config,
19 discourse_name: &str,
20 topic_id: u64,
21 local_path: Option<&Path>,
22 full: bool,
23) -> Result<()> {
24 let discourse = select_discourse(config, Some(discourse_name))?;
25 ensure_api_credentials(discourse)?;
26 let client = DiscourseClient::new(discourse)?;
27
28 if full {
29 let topic = client.fetch_topic_all_posts(topic_id)?;
30 let title = topic_display_title(&topic, topic_id);
31 let body = render_full_thread(&topic, topic_id, &discourse.baseurl);
32 let target = resolve_topic_path(local_path, &title, &std::env::current_dir()?)?;
33 write_markdown(&target, &body)?;
34 println!(
35 "Topic pulled (full thread, {} posts) to: {}",
36 topic.post_stream.posts.len(),
37 target.display()
38 );
39 return Ok(());
40 }
41
42 let topic = client.fetch_topic(topic_id, true)?;
43 let raw = topic
44 .post_stream
45 .posts
46 .first()
47 .and_then(|p| p.raw.clone())
48 .ok_or_else(|| anyhow!("topic has no raw content"))?;
49 let title = topic_display_title(&topic, topic_id);
50 let target = resolve_topic_path(local_path, &title, &std::env::current_dir()?)?;
51 write_markdown(&target, &raw)?;
52 println!("Topic pulled to: {}", target.display());
53 Ok(())
54}
55
56fn topic_display_title(topic: &TopicResponse, topic_id: u64) -> String {
60 topic
61 .title
62 .as_deref()
63 .filter(|t| !t.trim().is_empty())
64 .map(|t| t.to_string())
65 .or_else(|| {
66 topic
67 .slug
68 .as_deref()
69 .filter(|s| !s.trim().is_empty())
70 .map(|s| s.to_string())
71 })
72 .unwrap_or_else(|| format!("topic-{}", topic_id))
73}
74
75fn render_full_thread(topic: &TopicResponse, topic_id: u64, baseurl: &str) -> String {
80 let title = topic_display_title(topic, topic_id);
81 let slug = topic
82 .slug
83 .as_deref()
84 .filter(|s| !s.trim().is_empty())
85 .unwrap_or("topic");
86 let base_trimmed = baseurl.trim_end_matches('/');
87 let url = format!("{}/t/{}/{}", base_trimmed, slug, topic_id);
88 let posts_count = topic.post_stream.posts.len();
89 let pulled_at = current_utc_iso8601();
90
91 let mut out = String::new();
92 out.push_str("---\n");
93 out.push_str(&format!("title: {}\n", yaml_scalar(&title)));
94 out.push_str(&format!("topic_id: {}\n", topic_id));
95 out.push_str(&format!("url: {}\n", url));
96 out.push_str(&format!("posts_count: {}\n", posts_count));
97 out.push_str(&format!("pulled_at: {}\n", pulled_at));
98 out.push_str("---\n\n");
99
100 for (idx, post) in topic.post_stream.posts.iter().enumerate() {
101 if idx > 0 {
102 out.push_str("\n---\n\n");
103 }
104 let post_number = post.post_number.unwrap_or((idx + 1) as u64);
105 let username = post.username.as_deref().unwrap_or("(unknown)");
106 let date = post
107 .created_at
108 .as_deref()
109 .map(format_date_only)
110 .unwrap_or_else(|| "(no date)".to_string());
111 out.push_str(&format!(
112 "## Post {} · {} · {}\n\n",
113 post_number, username, date
114 ));
115 if let Some(raw) = post.raw.as_deref() {
116 out.push_str(raw.trim_end());
117 out.push('\n');
118 } else {
119 out.push_str("_(raw content unavailable)_\n");
120 }
121 }
122 out
123}
124
125fn format_date_only(ts: &str) -> String {
128 match ts.find('T') {
129 Some(idx) => ts[..idx].to_string(),
130 None => ts.to_string(),
131 }
132}
133
134pub fn topic_push(
135 config: &Config,
136 discourse_name: &str,
137 topic_id: u64,
138 local_path: &Path,
139 dry_run: bool,
140 edit_opts: PostEditOptions,
141) -> Result<()> {
142 let discourse = select_discourse(config, Some(discourse_name))?;
143 ensure_api_credentials(discourse)?;
144 let client = DiscourseClient::new(discourse)?;
145 let topic = client.fetch_topic(topic_id, true)?;
146 let post = topic
147 .post_stream
148 .posts
149 .first()
150 .ok_or_else(|| anyhow!("topic has no posts"))?;
151 let raw = read_markdown(local_path)?;
152 let (_front, body) = strip_frontmatter(&raw);
156 if dry_run {
157 println!(
158 "[dry-run] {}: would replace OP of topic {} (post id {}) with {} bytes from {}",
159 discourse.name,
160 topic_id,
161 post.id,
162 body.len(),
163 local_path.display()
164 );
165 return Ok(());
166 }
167 client.update_post(post.id, &body, edit_opts)?;
168 Ok(())
169}
170
171pub fn topic_sync(
172 config: &Config,
173 discourse_name: &str,
174 topic_id: u64,
175 local_path: &Path,
176 assume_yes: bool,
177) -> Result<()> {
178 let discourse = select_discourse(config, Some(discourse_name))?;
179 ensure_api_credentials(discourse)?;
180 let client = DiscourseClient::new(discourse)?;
181 let topic = client.fetch_topic(topic_id, true)?;
182 let post = topic
183 .post_stream
184 .posts
185 .first()
186 .ok_or_else(|| anyhow!("topic has no posts"))?;
187 let local_meta =
188 fs::metadata(local_path).with_context(|| format!("reading {}", local_path.display()))?;
189 let local_mtime = local_meta.modified()?;
190
191 let remote_ts = post
192 .updated_at
193 .as_deref()
194 .or(post.created_at.as_deref())
195 .ok_or_else(|| anyhow!("missing remote timestamps"))?;
196 let remote_time = chrono::DateTime::parse_from_rfc3339(remote_ts)
197 .context("parsing remote timestamp")?
198 .with_timezone(&chrono::Utc);
199
200 println!(
201 "Local file: {}",
202 chrono::DateTime::<chrono::Utc>::from(local_mtime)
203 );
204 println!("Remote post: {}", remote_time);
205
206 let pull = remote_time > chrono::DateTime::<chrono::Utc>::from(local_mtime);
207 if !assume_yes && !confirm_sync(pull)? {
208 return Ok(());
209 }
210
211 if pull {
212 let raw = post
213 .raw
214 .clone()
215 .ok_or_else(|| anyhow!("missing raw content"))?;
216 write_markdown(local_path, &raw)?;
217 } else {
218 let raw = read_markdown(local_path)?;
219 client.update_post(post.id, &raw, PostEditOptions::default())?;
220 }
221
222 Ok(())
223}
224
225pub fn topic_reply(
226 config: &Config,
227 discourse_name: &str,
228 topic_id: u64,
229 local_path: Option<&Path>,
230 dry_run: bool,
231 format: ListFormat,
232) -> Result<()> {
233 let discourse = select_discourse(config, Some(discourse_name))?;
234 ensure_api_credentials(discourse)?;
235 let client = DiscourseClient::new(discourse)?;
236
237 let raw = read_reply_input(local_path)?;
238 if raw.trim().is_empty() {
239 return Err(anyhow!("reply body is empty"));
240 }
241
242 if dry_run {
243 return emit_result(
244 format,
245 &json!({ "dry_run": true, "topic_id": topic_id, "bytes": raw.len() }),
246 &format!(
247 "[dry-run] {}: would reply to topic {} with {} bytes",
248 discourse.name,
249 topic_id,
250 raw.len()
251 ),
252 );
253 }
254
255 let post_id = client.create_post(topic_id, &raw)?;
256 emit_result(
257 format,
258 &json!({ "topic_id": topic_id, "post_id": post_id }),
259 &format!("Replied to topic {} (post id {})", topic_id, post_id),
260 )
261}
262
263pub fn topic_new(
264 config: &Config,
265 discourse_name: &str,
266 category_id: u64,
267 title: &str,
268 local_path: Option<&Path>,
269 dry_run: bool,
270 format: ListFormat,
271) -> Result<()> {
272 let discourse = select_discourse(config, Some(discourse_name))?;
273 ensure_api_credentials(discourse)?;
274 let client = DiscourseClient::new(discourse)?;
275
276 if title.trim().is_empty() {
277 return Err(anyhow!("topic title is empty"));
278 }
279 let raw = read_reply_input(local_path)?;
280 if raw.trim().is_empty() {
281 return Err(anyhow!("topic body is empty"));
282 }
283
284 if dry_run {
285 return emit_result(
286 format,
287 &json!({ "dry_run": true, "category_id": category_id, "title": title }),
288 &format!(
289 "[dry-run] {}: would create topic in category {} titled \"{}\" ({} bytes of body)",
290 discourse.name,
291 category_id,
292 title,
293 raw.len()
294 ),
295 );
296 }
297
298 let topic_id = client.create_topic(category_id, title, &raw)?;
299 emit_result(
300 format,
301 &json!({ "topic_id": topic_id, "category_id": category_id }),
302 &format!("Created topic {} in category {}", topic_id, category_id),
303 )
304}
305
306pub fn topic_title(
309 config: &Config,
310 discourse_name: &str,
311 topic_id: u64,
312 title: &str,
313 dry_run: bool,
314) -> Result<()> {
315 let discourse = select_discourse(config, Some(discourse_name))?;
316 ensure_api_credentials(discourse)?;
317 let client = DiscourseClient::new(discourse)?;
318 if title.trim().is_empty() {
319 return Err(anyhow!("new title is empty"));
320 }
321 let topic = client.fetch_topic(topic_id, false)?;
322 let old_title = topic.title.as_deref().unwrap_or("(unknown)");
323 let old_slug = topic.slug.as_deref().unwrap_or("topic");
324 let new_slug = slugify(title);
325 let url_note = if old_slug != new_slug {
326 format!(
327 "note: topic URL {} change from /t/{}/{} to /t/{}/{}",
328 if dry_run { "would" } else { "changed" },
329 old_slug,
330 topic_id,
331 new_slug,
332 topic_id
333 )
334 } else {
335 String::new()
336 };
337
338 if dry_run {
339 println!(
340 "[dry-run] {}: would rename topic {}: \"{}\" → \"{}\"",
341 discourse.name, topic_id, old_title, title
342 );
343 if !url_note.is_empty() {
344 println!("{}", url_note);
345 }
346 return Ok(());
347 }
348
349 client.set_topic_title(topic_id, title)?;
350 println!(
351 "renamed topic {}: \"{}\" → \"{}\"",
352 topic_id, old_title, title
353 );
354 if !url_note.is_empty() {
355 println!("{}", url_note);
356 }
357 Ok(())
358}
359
360pub fn topic_tags(
364 config: &Config,
365 discourse_name: &str,
366 topic_id: u64,
367 tags: &[String],
368 dry_run: bool,
369) -> Result<()> {
370 let discourse = select_discourse(config, Some(discourse_name))?;
371 ensure_api_credentials(discourse)?;
372 let client = DiscourseClient::new(discourse)?;
373 let current = client.fetch_topic_tags(topic_id)?;
374
375 if dry_run {
376 println!(
377 "[dry-run] {}: would set tags on topic {}: [{}] → [{}]",
378 discourse.name,
379 topic_id,
380 current.join(", "),
381 tags.join(", ")
382 );
383 return Ok(());
384 }
385
386 let after = client.set_topic_tags(topic_id, tags)?;
387 println!(
388 "tags set on topic {}: [{}] → [{}]",
389 topic_id,
390 current.join(", "),
391 after.join(", ")
392 );
393 Ok(())
394}
395
396fn read_reply_input(local_path: Option<&Path>) -> Result<String> {
397 let from_stdin = match local_path {
398 None => true,
399 Some(p) => p.as_os_str() == "-",
400 };
401 if from_stdin {
402 let mut buf = String::new();
403 io::stdin()
404 .read_to_string(&mut buf)
405 .context("reading reply from stdin")?;
406 Ok(buf)
407 } else {
408 let path = local_path.unwrap();
409 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::{format_date_only, read_reply_input, render_full_thread, topic_display_title};
416 use crate::api::{Post, PostStream, TopicResponse};
417 use crate::utils::yaml_scalar;
418 use std::io::Write;
419 use tempfile::NamedTempFile;
420
421 fn make_topic(title: Option<&str>, posts: Vec<Post>, stream: Vec<u64>) -> TopicResponse {
422 TopicResponse {
423 title: title.map(|s| s.to_string()),
424 slug: Some("hello-world".to_string()),
425 post_stream: PostStream { posts, stream },
426 }
427 }
428
429 fn make_post(
430 id: u64,
431 post_number: Option<u64>,
432 username: Option<&str>,
433 raw: Option<&str>,
434 created_at: Option<&str>,
435 ) -> Post {
436 Post {
437 id,
438 post_number,
439 username: username.map(|s| s.to_string()),
440 raw: raw.map(|s| s.to_string()),
441 updated_at: None,
442 created_at: created_at.map(|s| s.to_string()),
443 }
444 }
445
446 #[test]
447 fn read_reply_input_reads_from_file() {
448 let mut f = NamedTempFile::new().unwrap();
449 writeln!(f, "hello from file").unwrap();
450 let got = read_reply_input(Some(f.path())).unwrap();
451 assert_eq!(got.trim(), "hello from file");
452 }
453
454 #[test]
455 fn read_reply_input_missing_file_surfaces_path_in_error() {
456 let bogus = std::path::Path::new("/definitely/does/not/exist.md");
457 let err = read_reply_input(Some(bogus)).unwrap_err();
458 let msg = format!("{:#}", err);
459 assert!(msg.contains("/definitely/does/not/exist.md"));
460 }
461
462 #[test]
463 fn display_title_prefers_title_then_slug_then_fallback() {
464 let t1 = make_topic(Some("My Title"), vec![], vec![]);
465 assert_eq!(topic_display_title(&t1, 42), "My Title");
466
467 let t2 = TopicResponse {
468 title: Some(" ".to_string()),
469 slug: Some("my-slug".to_string()),
470 post_stream: PostStream::default(),
471 };
472 assert_eq!(topic_display_title(&t2, 42), "my-slug");
473
474 let t3 = TopicResponse {
475 title: None,
476 slug: None,
477 post_stream: PostStream::default(),
478 };
479 assert_eq!(topic_display_title(&t3, 42), "topic-42");
480 }
481
482 #[test]
483 fn format_date_only_trims_at_t() {
484 assert_eq!(format_date_only("2026-03-24T11:07:00Z"), "2026-03-24");
485 assert_eq!(format_date_only("2026-03-24"), "2026-03-24");
486 assert_eq!(format_date_only(""), "");
487 }
488
489 #[test]
490 fn yaml_scalar_quotes_when_ambiguous() {
491 assert_eq!(yaml_scalar("simple title"), "simple title");
492 assert_eq!(yaml_scalar("a: b"), "\"a: b\"");
494 assert_eq!(yaml_scalar("#hash"), "\"#hash\"");
496 assert_eq!(yaml_scalar("\"q"), "\"\\\"q\"");
498 assert_eq!(yaml_scalar("she said hi"), "she said hi");
500 }
501
502 #[test]
503 fn render_full_thread_emits_frontmatter_and_per_post_headings() {
504 let posts = vec![
505 make_post(
506 101,
507 Some(1),
508 Some("alice"),
509 Some("hello"),
510 Some("2026-03-24T11:00:00Z"),
511 ),
512 make_post(
513 102,
514 Some(2),
515 Some("bob"),
516 Some("hi back"),
517 Some("2026-03-25T09:00:00Z"),
518 ),
519 ];
520 let topic = make_topic(Some("Hello World"), posts, vec![101, 102]);
521 let out = render_full_thread(&topic, 42, "https://forum.example.com/");
522
523 assert!(out.starts_with("---\n"));
524 assert!(out.contains("title: Hello World\n"));
525 assert!(out.contains("topic_id: 42\n"));
526 assert!(out.contains("url: https://forum.example.com/t/hello-world/42\n"));
527 assert!(out.contains("posts_count: 2\n"));
528 assert!(out.contains("## Post 1 · alice · 2026-03-24\n"));
529 assert!(out.contains("## Post 2 · bob · 2026-03-25\n"));
530 assert!(out.contains("hello"));
531 assert!(out.contains("hi back"));
532 assert!(out.contains("\n---\n"), "horizontal rule between posts");
533 }
534
535 #[test]
536 fn render_full_thread_handles_missing_raw_and_user() {
537 let posts = vec![make_post(7, Some(1), None, None, None)];
538 let topic = make_topic(None, posts, vec![7]);
539 let out = render_full_thread(&topic, 7, "https://x.test");
540 assert!(out.contains("(unknown)"));
541 assert!(out.contains("(no date)"));
542 assert!(out.contains("_(raw content unavailable)_"));
543 }
544
545 #[test]
546 fn render_full_thread_falls_back_to_index_when_post_number_missing() {
547 let posts = vec![make_post(7, None, Some("alice"), Some("body"), None)];
548 let topic = make_topic(Some("t"), posts, vec![7]);
549 let out = render_full_thread(&topic, 7, "https://x.test");
550 assert!(out.contains("## Post 1 · alice"));
552 }
553}
554
555fn confirm_sync(pull: bool) -> Result<bool> {
556 let action = if pull {
557 "pull from Discourse"
558 } else {
559 "push to Discourse"
560 };
561 print!("Proceed to {}? [y/N]: ", action);
562 io::stdout().flush()?;
563 let mut input = String::new();
564 io::stdin().read_line(&mut input)?;
565 Ok(matches!(input.trim(), "y" | "Y" | "yes" | "YES"))
566}