Skip to main content

dsc/commands/
topic.rs

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
56/// Pick a stable display string for the topic - title, then slug, then a
57/// `topic-N` fallback. Used for both filename derivation and Markdown
58/// frontmatter.
59fn 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
75/// Render every post in `topic` as a single Markdown document with YAML
76/// frontmatter (title / topic_id / url / posts_count / pulled_at) and
77/// per-post `## Post N · username · date` headings separated by `---`
78/// horizontal rules.
79fn 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
125/// Trim an ISO-8601 timestamp like `2026-03-24T11:07:00.123Z` down to the
126/// date portion. Leaves anything that doesn't parse cleanly as-is.
127fn 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    // Strip any YAML front matter so a manually-annotated file (or one carried
153    // over from a `category pull`) pushes a clean body — the `---` block is
154    // local-only metadata and must never reach the published post.
155    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
306/// Rename a topic's title (`PUT /t/{id}.json` with `title=`). Renaming
307/// changes the slug, so the topic URL changes too - the user is warned.
308pub 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
360/// Replace a topic's full tag list (`PUT /t/{id}.json` with `tags[]=`).
361/// Passing zero tags clears all tags. Unlike `topic tag`/`untag` (which add
362/// or remove one tag), this sets the list atomically.
363pub 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        // Colon triggers quoting.
493        assert_eq!(yaml_scalar("a: b"), "\"a: b\"");
494        // Leading hash would otherwise read as a comment.
495        assert_eq!(yaml_scalar("#hash"), "\"#hash\"");
496        // Leading quote forces quoting + escapes inner quotes.
497        assert_eq!(yaml_scalar("\"q"), "\"\\\"q\"");
498        // Embedded quotes mid-string are fine in plain YAML scalars.
499        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        // Single post with no post_number → numbered 1 from index.
551        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}