Skip to main content

rustant_tools/
content_engine.rs

1//! Content engine tool — multi-platform content pipeline with lifecycle tracking.
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use rustant_core::error::ToolError;
6use rustant_core::types::{RiskLevel, ToolOutput};
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use std::path::PathBuf;
10
11use crate::registry::Tool;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14enum ContentPlatform {
15    Blog,
16    Twitter,
17    LinkedIn,
18    GitHub,
19    Medium,
20    Newsletter,
21}
22
23impl ContentPlatform {
24    fn from_str_loose(s: &str) -> Option<Self> {
25        match s.to_lowercase().as_str() {
26            "blog" => Some(Self::Blog),
27            "twitter" => Some(Self::Twitter),
28            "linkedin" => Some(Self::LinkedIn),
29            "github" => Some(Self::GitHub),
30            "medium" => Some(Self::Medium),
31            "newsletter" => Some(Self::Newsletter),
32            _ => None,
33        }
34    }
35
36    fn as_str(&self) -> &str {
37        match self {
38            Self::Blog => "Blog",
39            Self::Twitter => "Twitter",
40            Self::LinkedIn => "LinkedIn",
41            Self::GitHub => "GitHub",
42            Self::Medium => "Medium",
43            Self::Newsletter => "Newsletter",
44        }
45    }
46}
47
48impl std::fmt::Display for ContentPlatform {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "{}", self.as_str())
51    }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55enum ContentStatus {
56    Idea,
57    Draft,
58    Review,
59    Scheduled,
60    Published,
61    Archived,
62}
63
64impl ContentStatus {
65    fn from_str_loose(s: &str) -> Option<Self> {
66        match s.to_lowercase().as_str() {
67            "idea" => Some(Self::Idea),
68            "draft" => Some(Self::Draft),
69            "review" => Some(Self::Review),
70            "scheduled" => Some(Self::Scheduled),
71            "published" => Some(Self::Published),
72            "archived" => Some(Self::Archived),
73            _ => None,
74        }
75    }
76
77    fn as_str(&self) -> &str {
78        match self {
79            Self::Idea => "Idea",
80            Self::Draft => "Draft",
81            Self::Review => "Review",
82            Self::Scheduled => "Scheduled",
83            Self::Published => "Published",
84            Self::Archived => "Archived",
85        }
86    }
87}
88
89impl std::fmt::Display for ContentStatus {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(f, "{}", self.as_str())
92    }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96struct ContentPiece {
97    id: usize,
98    title: String,
99    body: String,
100    platform: ContentPlatform,
101    status: ContentStatus,
102    audience: String,
103    tone: String,
104    tags: Vec<String>,
105    word_count: usize,
106    created_at: DateTime<Utc>,
107    updated_at: DateTime<Utc>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    scheduled_for: Option<DateTime<Utc>>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    published_at: Option<DateTime<Utc>>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115struct CalendarEntry {
116    date: String, // YYYY-MM-DD format
117    platform: ContentPlatform,
118    topic: String,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    content_id: Option<usize>,
121    #[serde(default)]
122    notes: String,
123}
124
125#[derive(Debug, Default, Serialize, Deserialize)]
126struct ContentState {
127    pieces: Vec<ContentPiece>,
128    calendar: Vec<CalendarEntry>,
129    next_id: usize,
130}
131
132fn count_words(text: &str) -> usize {
133    text.split_whitespace().count()
134}
135
136pub struct ContentEngineTool {
137    workspace: PathBuf,
138}
139
140impl ContentEngineTool {
141    pub fn new(workspace: PathBuf) -> Self {
142        Self { workspace }
143    }
144
145    fn state_path(&self) -> PathBuf {
146        self.workspace
147            .join(".rustant")
148            .join("content")
149            .join("library.json")
150    }
151
152    fn load_state(&self) -> ContentState {
153        let path = self.state_path();
154        if path.exists() {
155            std::fs::read_to_string(&path)
156                .ok()
157                .and_then(|s| serde_json::from_str(&s).ok())
158                .unwrap_or_default()
159        } else {
160            ContentState {
161                pieces: Vec::new(),
162                calendar: Vec::new(),
163                next_id: 1,
164            }
165        }
166    }
167
168    fn save_state(&self, state: &ContentState) -> Result<(), ToolError> {
169        let path = self.state_path();
170        if let Some(parent) = path.parent() {
171            std::fs::create_dir_all(parent).map_err(|e| ToolError::ExecutionFailed {
172                name: "content_engine".to_string(),
173                message: format!("Failed to create state dir: {}", e),
174            })?;
175        }
176        let json = serde_json::to_string_pretty(state).map_err(|e| ToolError::ExecutionFailed {
177            name: "content_engine".to_string(),
178            message: format!("Failed to serialize state: {}", e),
179        })?;
180        let tmp = path.with_extension("json.tmp");
181        std::fs::write(&tmp, &json).map_err(|e| ToolError::ExecutionFailed {
182            name: "content_engine".to_string(),
183            message: format!("Failed to write state: {}", e),
184        })?;
185        std::fs::rename(&tmp, &path).map_err(|e| ToolError::ExecutionFailed {
186            name: "content_engine".to_string(),
187            message: format!("Failed to rename state file: {}", e),
188        })?;
189        Ok(())
190    }
191
192    fn find_piece(pieces: &[ContentPiece], id: usize) -> Option<usize> {
193        pieces.iter().position(|p| p.id == id)
194    }
195
196    fn format_piece_summary(piece: &ContentPiece) -> String {
197        let scheduled = piece
198            .scheduled_for
199            .map(|d| format!(" | Scheduled: {}", d.format("%Y-%m-%d %H:%M")))
200            .unwrap_or_default();
201        let published = piece
202            .published_at
203            .map(|d| format!(" | Published: {}", d.format("%Y-%m-%d %H:%M")))
204            .unwrap_or_default();
205        let tags = if piece.tags.is_empty() {
206            String::new()
207        } else {
208            format!(" [{}]", piece.tags.join(", "))
209        };
210        format!(
211            "  #{} — {} ({}, {}) {} words{}{}{}",
212            piece.id,
213            piece.title,
214            piece.platform,
215            piece.status,
216            piece.word_count,
217            tags,
218            scheduled,
219            published,
220        )
221    }
222
223    fn format_piece_detail(piece: &ContentPiece) -> String {
224        let mut out = String::new();
225        out.push_str(&format!("Content #{}\n", piece.id));
226        out.push_str(&format!("  Title:    {}\n", piece.title));
227        out.push_str(&format!("  Platform: {}\n", piece.platform));
228        out.push_str(&format!("  Status:   {}\n", piece.status));
229        out.push_str(&format!("  Audience: {}\n", piece.audience));
230        out.push_str(&format!("  Tone:     {}\n", piece.tone));
231        out.push_str(&format!("  Words:    {}\n", piece.word_count));
232        if !piece.tags.is_empty() {
233            out.push_str(&format!("  Tags:     {}\n", piece.tags.join(", ")));
234        }
235        out.push_str(&format!(
236            "  Created:  {}\n",
237            piece.created_at.format("%Y-%m-%d %H:%M")
238        ));
239        out.push_str(&format!(
240            "  Updated:  {}\n",
241            piece.updated_at.format("%Y-%m-%d %H:%M")
242        ));
243        if let Some(s) = piece.scheduled_for {
244            out.push_str(&format!("  Scheduled: {}\n", s.format("%Y-%m-%d %H:%M")));
245        }
246        if let Some(p) = piece.published_at {
247            out.push_str(&format!("  Published: {}\n", p.format("%Y-%m-%d %H:%M")));
248        }
249        if !piece.body.is_empty() {
250            out.push_str(&format!("\n--- Body ---\n{}\n", piece.body));
251        }
252        out
253    }
254
255    fn platform_constraints(platform: &ContentPlatform) -> &'static str {
256        match platform {
257            ContentPlatform::Twitter => {
258                "Twitter: Max 280 characters. Use concise, punchy language. Include relevant hashtags. Encourage engagement (questions, polls)."
259            }
260            ContentPlatform::LinkedIn => {
261                "LinkedIn: Professional tone. Use clear structure with line breaks. Open with a hook. End with a call-to-action. Keep under 3000 characters for best engagement."
262            }
263            ContentPlatform::Blog => {
264                "Blog: Long-form with headers, subheaders, and paragraphs. Include an introduction and conclusion. SEO-friendly with keywords. Target 800-2000 words."
265            }
266            ContentPlatform::GitHub => {
267                "GitHub: Technical and precise. Use Markdown formatting. Include code examples where relevant. Be concise and actionable."
268            }
269            ContentPlatform::Medium => {
270                "Medium: Storytelling format. Use a compelling title and subtitle. Break into sections with subheadings. Include images/quotes. Target 5-7 minute read (1000-1500 words)."
271            }
272            ContentPlatform::Newsletter => {
273                "Newsletter: Engaging and personable. Use a strong subject line hook. Keep sections scannable. Include clear CTAs. Balance value with brevity."
274            }
275        }
276    }
277}
278
279#[async_trait]
280impl Tool for ContentEngineTool {
281    fn name(&self) -> &str {
282        "content_engine"
283    }
284
285    fn description(&self) -> &str {
286        "Multi-platform content pipeline with lifecycle tracking. Actions: create, update, set_status, get, list, search, delete, schedule, calendar_add, calendar_list, calendar_remove, stats, adapt, export_markdown."
287    }
288
289    fn parameters_schema(&self) -> Value {
290        json!({
291            "type": "object",
292            "properties": {
293                "action": {
294                    "type": "string",
295                    "enum": [
296                        "create", "update", "set_status", "get", "list", "search",
297                        "delete", "schedule", "calendar_add", "calendar_list",
298                        "calendar_remove", "stats", "adapt", "export_markdown"
299                    ],
300                    "description": "Action to perform"
301                },
302                "id": { "type": "integer", "description": "Content piece ID" },
303                "title": { "type": "string", "description": "Content title" },
304                "body": { "type": "string", "description": "Content body text" },
305                "platform": { "type": "string", "description": "Platform: blog, twitter, linkedin, github, medium, newsletter" },
306                "status": { "type": "string", "description": "Status: idea, draft, review, scheduled, published, archived" },
307                "audience": { "type": "string", "description": "Target audience" },
308                "tone": { "type": "string", "description": "Writing tone (e.g., casual, formal, technical)" },
309                "tags": { "type": "array", "items": { "type": "string" }, "description": "Content tags" },
310                "tag": { "type": "string", "description": "Single tag filter for list" },
311                "query": { "type": "string", "description": "Search query" },
312                "date": { "type": "string", "description": "Date in YYYY-MM-DD format" },
313                "time": { "type": "string", "description": "Time in HH:MM format (for schedule)" },
314                "month": { "type": "string", "description": "Month filter in YYYY-MM format" },
315                "topic": { "type": "string", "description": "Calendar entry topic" },
316                "content_id": { "type": "integer", "description": "Linked content piece ID for calendar" },
317                "notes": { "type": "string", "description": "Calendar entry notes" },
318                "target_platform": { "type": "string", "description": "Target platform for adapt action" },
319                "target_tone": { "type": "string", "description": "Target tone for adapt action" }
320            },
321            "required": ["action"]
322        })
323    }
324
325    fn risk_level(&self) -> RiskLevel {
326        RiskLevel::Write
327    }
328
329    fn timeout(&self) -> std::time::Duration {
330        std::time::Duration::from_secs(30)
331    }
332
333    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
334        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
335        let mut state = self.load_state();
336
337        match action {
338            "create" => {
339                let title = args
340                    .get("title")
341                    .and_then(|v| v.as_str())
342                    .unwrap_or("")
343                    .trim();
344                if title.is_empty() {
345                    return Ok(ToolOutput::text("Please provide a title for the content."));
346                }
347                let platform_str = args
348                    .get("platform")
349                    .and_then(|v| v.as_str())
350                    .unwrap_or("blog");
351                let platform = ContentPlatform::from_str_loose(platform_str).unwrap_or(ContentPlatform::Blog);
352                let body = args
353                    .get("body")
354                    .and_then(|v| v.as_str())
355                    .unwrap_or("")
356                    .to_string();
357                let audience = args
358                    .get("audience")
359                    .and_then(|v| v.as_str())
360                    .unwrap_or("")
361                    .to_string();
362                let tone = args
363                    .get("tone")
364                    .and_then(|v| v.as_str())
365                    .unwrap_or("")
366                    .to_string();
367                let tags: Vec<String> = args
368                    .get("tags")
369                    .and_then(|v| v.as_array())
370                    .map(|arr| {
371                        arr.iter()
372                            .filter_map(|v| v.as_str().map(String::from))
373                            .collect()
374                    })
375                    .unwrap_or_default();
376
377                let word_count = count_words(&body);
378                let status = if body.is_empty() {
379                    ContentStatus::Idea
380                } else {
381                    ContentStatus::Draft
382                };
383
384                let id = state.next_id;
385                state.next_id += 1;
386                let now = Utc::now();
387                state.pieces.push(ContentPiece {
388                    id,
389                    title: title.to_string(),
390                    body,
391                    platform: platform.clone(),
392                    status: status.clone(),
393                    audience,
394                    tone,
395                    tags,
396                    word_count,
397                    created_at: now,
398                    updated_at: now,
399                    scheduled_for: None,
400                    published_at: None,
401                });
402                self.save_state(&state)?;
403
404                Ok(ToolOutput::text(format!(
405                    "Created content #{} '{}' ({}, {}).",
406                    id, title, platform, status
407                )))
408            }
409
410            "update" => {
411                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
412                let idx = match Self::find_piece(&state.pieces, id) {
413                    Some(i) => i,
414                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
415                };
416
417                if let Some(title) = args.get("title").and_then(|v| v.as_str()) {
418                    state.pieces[idx].title = title.to_string();
419                }
420                if let Some(body) = args.get("body").and_then(|v| v.as_str()) {
421                    state.pieces[idx].body = body.to_string();
422                    state.pieces[idx].word_count = count_words(body);
423                }
424                if let Some(platform_str) = args.get("platform").and_then(|v| v.as_str()) {
425                    if let Some(p) = ContentPlatform::from_str_loose(platform_str) {
426                        state.pieces[idx].platform = p;
427                    }
428                }
429                if let Some(audience) = args.get("audience").and_then(|v| v.as_str()) {
430                    state.pieces[idx].audience = audience.to_string();
431                }
432                if let Some(tone) = args.get("tone").and_then(|v| v.as_str()) {
433                    state.pieces[idx].tone = tone.to_string();
434                }
435                state.pieces[idx].updated_at = Utc::now();
436                self.save_state(&state)?;
437
438                Ok(ToolOutput::text(format!(
439                    "Updated content #{} '{}'.",
440                    id, state.pieces[idx].title
441                )))
442            }
443
444            "set_status" => {
445                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
446                let status_str = args
447                    .get("status")
448                    .and_then(|v| v.as_str())
449                    .unwrap_or("");
450                let new_status = match ContentStatus::from_str_loose(status_str) {
451                    Some(s) => s,
452                    None => {
453                        return Ok(ToolOutput::text(format!(
454                            "Unknown status '{}'. Use: idea, draft, review, scheduled, published, archived.",
455                            status_str
456                        )));
457                    }
458                };
459                let idx = match Self::find_piece(&state.pieces, id) {
460                    Some(i) => i,
461                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
462                };
463
464                state.pieces[idx].status = new_status.clone();
465                state.pieces[idx].updated_at = Utc::now();
466                if new_status == ContentStatus::Published {
467                    state.pieces[idx].published_at = Some(Utc::now());
468                }
469                self.save_state(&state)?;
470
471                Ok(ToolOutput::text(format!(
472                    "Content #{} status set to {}.",
473                    id, new_status
474                )))
475            }
476
477            "get" => {
478                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
479                match Self::find_piece(&state.pieces, id) {
480                    Some(idx) => Ok(ToolOutput::text(Self::format_piece_detail(
481                        &state.pieces[idx],
482                    ))),
483                    None => Ok(ToolOutput::text(format!("Content #{} not found.", id))),
484                }
485            }
486
487            "list" => {
488                let platform_filter = args
489                    .get("platform")
490                    .and_then(|v| v.as_str())
491                    .and_then(ContentPlatform::from_str_loose);
492                let status_filter = args
493                    .get("status")
494                    .and_then(|v| v.as_str())
495                    .and_then(ContentStatus::from_str_loose);
496                let tag_filter = args.get("tag").and_then(|v| v.as_str());
497
498                let filtered: Vec<&ContentPiece> = state
499                    .pieces
500                    .iter()
501                    .filter(|p| {
502                        platform_filter
503                            .as_ref()
504                            .map(|pf| p.platform == *pf)
505                            .unwrap_or(true)
506                    })
507                    .filter(|p| {
508                        status_filter
509                            .as_ref()
510                            .map(|sf| p.status == *sf)
511                            .unwrap_or(true)
512                    })
513                    .filter(|p| {
514                        tag_filter
515                            .map(|t| p.tags.iter().any(|tag| tag.eq_ignore_ascii_case(t)))
516                            .unwrap_or(true)
517                    })
518                    .collect();
519
520                if filtered.is_empty() {
521                    return Ok(ToolOutput::text("No content pieces found."));
522                }
523
524                let lines: Vec<String> = filtered
525                    .into_iter()
526                    .map(Self::format_piece_summary)
527                    .collect();
528                Ok(ToolOutput::text(format!(
529                    "Content ({} pieces):\n{}",
530                    lines.len(),
531                    lines.join("\n")
532                )))
533            }
534
535            "search" => {
536                let query = args
537                    .get("query")
538                    .and_then(|v| v.as_str())
539                    .unwrap_or("")
540                    .to_lowercase();
541                if query.is_empty() {
542                    return Ok(ToolOutput::text("Please provide a search query."));
543                }
544
545                let matches: Vec<String> = state
546                    .pieces
547                    .iter()
548                    .filter(|p| {
549                        p.title.to_lowercase().contains(&query)
550                            || p.body.to_lowercase().contains(&query)
551                            || p.tags
552                                .iter()
553                                .any(|t| t.to_lowercase().contains(&query))
554                    })
555                    .map(Self::format_piece_summary)
556                    .collect();
557
558                if matches.is_empty() {
559                    Ok(ToolOutput::text(format!(
560                        "No content matching '{}'.",
561                        query
562                    )))
563                } else {
564                    Ok(ToolOutput::text(format!(
565                        "Found {} pieces:\n{}",
566                        matches.len(),
567                        matches.join("\n")
568                    )))
569                }
570            }
571
572            "delete" => {
573                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
574                let idx = match Self::find_piece(&state.pieces, id) {
575                    Some(i) => i,
576                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
577                };
578                let title = state.pieces[idx].title.clone();
579                state.pieces.remove(idx);
580                // Remove linked calendar entries
581                state
582                    .calendar
583                    .retain(|c| c.content_id != Some(id));
584                self.save_state(&state)?;
585
586                Ok(ToolOutput::text(format!(
587                    "Deleted content #{} '{}' and linked calendar entries.",
588                    id, title
589                )))
590            }
591
592            "schedule" => {
593                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
594                let date_str = args
595                    .get("date")
596                    .and_then(|v| v.as_str())
597                    .unwrap_or("");
598                if date_str.is_empty() {
599                    return Ok(ToolOutput::text(
600                        "Please provide a date in YYYY-MM-DD format.",
601                    ));
602                }
603                let time_str = args
604                    .get("time")
605                    .and_then(|v| v.as_str())
606                    .unwrap_or("09:00");
607
608                let datetime_str = format!("{}T{}:00Z", date_str, time_str);
609                let scheduled_dt = datetime_str
610                    .parse::<DateTime<Utc>>()
611                    .map_err(|e| ToolError::ExecutionFailed {
612                        name: "content_engine".to_string(),
613                        message: format!("Invalid date/time '{}': {}", datetime_str, e),
614                    })?;
615
616                let idx = match Self::find_piece(&state.pieces, id) {
617                    Some(i) => i,
618                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
619                };
620
621                state.pieces[idx].status = ContentStatus::Scheduled;
622                state.pieces[idx].scheduled_for = Some(scheduled_dt);
623                state.pieces[idx].updated_at = Utc::now();
624                self.save_state(&state)?;
625
626                Ok(ToolOutput::text(format!(
627                    "Content #{} '{}' scheduled for {} {}.",
628                    id, state.pieces[idx].title, date_str, time_str
629                )))
630            }
631
632            "calendar_add" => {
633                let date = args
634                    .get("date")
635                    .and_then(|v| v.as_str())
636                    .unwrap_or("")
637                    .to_string();
638                if date.is_empty() {
639                    return Ok(ToolOutput::text(
640                        "Please provide a date in YYYY-MM-DD format.",
641                    ));
642                }
643                let platform_str = args
644                    .get("platform")
645                    .and_then(|v| v.as_str())
646                    .unwrap_or("blog");
647                let platform =
648                    ContentPlatform::from_str_loose(platform_str).unwrap_or(ContentPlatform::Blog);
649                let topic = args
650                    .get("topic")
651                    .and_then(|v| v.as_str())
652                    .unwrap_or("")
653                    .to_string();
654                if topic.is_empty() {
655                    return Ok(ToolOutput::text("Please provide a topic."));
656                }
657                let content_id = args
658                    .get("content_id")
659                    .and_then(|v| v.as_u64())
660                    .map(|v| v as usize);
661                let notes = args
662                    .get("notes")
663                    .and_then(|v| v.as_str())
664                    .unwrap_or("")
665                    .to_string();
666
667                state.calendar.push(CalendarEntry {
668                    date: date.clone(),
669                    platform: platform.clone(),
670                    topic: topic.clone(),
671                    content_id,
672                    notes,
673                });
674                self.save_state(&state)?;
675
676                Ok(ToolOutput::text(format!(
677                    "Added calendar entry: {} on {} ({}).",
678                    topic, date, platform
679                )))
680            }
681
682            "calendar_list" => {
683                let month_filter = args.get("month").and_then(|v| v.as_str());
684                let platform_filter = args
685                    .get("platform")
686                    .and_then(|v| v.as_str())
687                    .and_then(ContentPlatform::from_str_loose);
688
689                let filtered: Vec<&CalendarEntry> = state
690                    .calendar
691                    .iter()
692                    .filter(|c| {
693                        month_filter
694                            .map(|m| c.date.starts_with(m))
695                            .unwrap_or(true)
696                    })
697                    .filter(|c| {
698                        platform_filter
699                            .as_ref()
700                            .map(|pf| c.platform == *pf)
701                            .unwrap_or(true)
702                    })
703                    .collect();
704
705                if filtered.is_empty() {
706                    return Ok(ToolOutput::text("No calendar entries found."));
707                }
708
709                let lines: Vec<String> = filtered
710                    .iter()
711                    .map(|c| {
712                        let linked = c
713                            .content_id
714                            .map(|id| format!(" (content #{})", id))
715                            .unwrap_or_default();
716                        let notes = if c.notes.is_empty() {
717                            String::new()
718                        } else {
719                            format!(" — {}", c.notes)
720                        };
721                        format!(
722                            "  {} | {} | {}{}{}",
723                            c.date, c.platform, c.topic, linked, notes
724                        )
725                    })
726                    .collect();
727
728                Ok(ToolOutput::text(format!(
729                    "Content calendar ({} entries):\n{}",
730                    filtered.len(),
731                    lines.join("\n")
732                )))
733            }
734
735            "calendar_remove" => {
736                let date = args
737                    .get("date")
738                    .and_then(|v| v.as_str())
739                    .unwrap_or("");
740                let platform_str = args
741                    .get("platform")
742                    .and_then(|v| v.as_str())
743                    .unwrap_or("");
744                let platform = match ContentPlatform::from_str_loose(platform_str) {
745                    Some(p) => p,
746                    None => {
747                        return Ok(ToolOutput::text(format!(
748                            "Unknown platform '{}'. Use: blog, twitter, linkedin, github, medium, newsletter.",
749                            platform_str
750                        )));
751                    }
752                };
753
754                let before = state.calendar.len();
755                state
756                    .calendar
757                    .retain(|c| !(c.date == date && c.platform == platform));
758                let removed = before - state.calendar.len();
759
760                if removed == 0 {
761                    return Ok(ToolOutput::text(format!(
762                        "No calendar entry found for {} on {}.",
763                        platform, date
764                    )));
765                }
766
767                self.save_state(&state)?;
768                Ok(ToolOutput::text(format!(
769                    "Removed {} calendar entry/entries for {} on {}.",
770                    removed, platform, date
771                )))
772            }
773
774            "stats" => {
775                if state.pieces.is_empty() {
776                    return Ok(ToolOutput::text("No content pieces yet."));
777                }
778
779                // Counts by status
780                let mut by_status: std::collections::HashMap<String, usize> =
781                    std::collections::HashMap::new();
782                for p in &state.pieces {
783                    *by_status.entry(p.status.as_str().to_string()).or_insert(0) += 1;
784                }
785
786                // Counts by platform
787                let mut by_platform: std::collections::HashMap<String, usize> =
788                    std::collections::HashMap::new();
789                for p in &state.pieces {
790                    *by_platform
791                        .entry(p.platform.as_str().to_string())
792                        .or_insert(0) += 1;
793                }
794
795                // Upcoming scheduled
796                let now = Utc::now();
797                let upcoming: Vec<&ContentPiece> = state
798                    .pieces
799                    .iter()
800                    .filter(|p| {
801                        p.status == ContentStatus::Scheduled
802                            && p.scheduled_for.map(|s| s > now).unwrap_or(false)
803                    })
804                    .collect();
805
806                // Total word count
807                let total_words: usize = state.pieces.iter().map(|p| p.word_count).sum();
808
809                let mut out = String::from("Content stats:\n");
810                out.push_str(&format!("  Total pieces: {}\n", state.pieces.len()));
811                out.push_str(&format!("  Total words:  {}\n\n", total_words));
812
813                out.push_str("  By status:\n");
814                let mut status_entries: Vec<_> = by_status.iter().collect();
815                status_entries.sort_by_key(|(k, _)| (*k).clone());
816                for (status, count) in &status_entries {
817                    out.push_str(&format!("    {}: {}\n", status, count));
818                }
819
820                out.push_str("\n  By platform:\n");
821                let mut platform_entries: Vec<_> = by_platform.iter().collect();
822                platform_entries.sort_by_key(|(k, _)| (*k).clone());
823                for (platform, count) in &platform_entries {
824                    out.push_str(&format!("    {}: {}\n", platform, count));
825                }
826
827                if !upcoming.is_empty() {
828                    out.push_str(&format!("\n  Upcoming scheduled: {}\n", upcoming.len()));
829                    for p in &upcoming {
830                        let date = p
831                            .scheduled_for
832                            .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
833                            .unwrap_or_default();
834                        out.push_str(&format!("    #{} '{}' — {}\n", p.id, p.title, date));
835                    }
836                }
837
838                Ok(ToolOutput::text(out))
839            }
840
841            "adapt" => {
842                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
843                let target_str = args
844                    .get("target_platform")
845                    .and_then(|v| v.as_str())
846                    .unwrap_or("");
847                let target_platform = match ContentPlatform::from_str_loose(target_str) {
848                    Some(p) => p,
849                    None => {
850                        return Ok(ToolOutput::text(format!(
851                            "Unknown target platform '{}'. Use: blog, twitter, linkedin, github, medium, newsletter.",
852                            target_str
853                        )));
854                    }
855                };
856                let target_tone = args
857                    .get("target_tone")
858                    .and_then(|v| v.as_str())
859                    .unwrap_or("");
860
861                let idx = match Self::find_piece(&state.pieces, id) {
862                    Some(i) => i,
863                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
864                };
865
866                let piece = &state.pieces[idx];
867                let constraints = Self::platform_constraints(&target_platform);
868
869                let mut prompt = String::new();
870                prompt.push_str(&format!(
871                    "Adapt the following content for {}.\n\n",
872                    target_platform
873                ));
874                prompt.push_str(&format!("Platform constraints:\n{}\n\n", constraints));
875                if !target_tone.is_empty() {
876                    prompt.push_str(&format!("Target tone: {}\n\n", target_tone));
877                } else if !piece.tone.is_empty() {
878                    prompt.push_str(&format!("Original tone: {}\n\n", piece.tone));
879                }
880                if !piece.audience.is_empty() {
881                    prompt.push_str(&format!("Target audience: {}\n\n", piece.audience));
882                }
883                prompt.push_str(&format!("Original title: {}\n", piece.title));
884                prompt.push_str(&format!(
885                    "Original platform: {}\n\n",
886                    piece.platform
887                ));
888                prompt.push_str(&format!("Original content:\n{}\n", piece.body));
889
890                Ok(ToolOutput::text(format!(
891                    "Adaptation prompt for #{} → {}:\n\n{}",
892                    id, target_platform, prompt
893                )))
894            }
895
896            "export_markdown" => {
897                let id_filter = args.get("id").and_then(|v| v.as_u64()).map(|v| v as usize);
898                let status_filter = args
899                    .get("status")
900                    .and_then(|v| v.as_str())
901                    .and_then(ContentStatus::from_str_loose);
902
903                let filtered: Vec<&ContentPiece> = state
904                    .pieces
905                    .iter()
906                    .filter(|p| id_filter.map(|id| p.id == id).unwrap_or(true))
907                    .filter(|p| {
908                        status_filter
909                            .as_ref()
910                            .map(|sf| p.status == *sf)
911                            .unwrap_or(true)
912                    })
913                    .collect();
914
915                if filtered.is_empty() {
916                    return Ok(ToolOutput::text("No content to export."));
917                }
918
919                let mut md = String::new();
920                for piece in &filtered {
921                    md.push_str(&format!("# {}\n\n", piece.title));
922                    md.push_str(&format!(
923                        "**Platform:** {} | **Status:** {} | **Words:** {}\n\n",
924                        piece.platform, piece.status, piece.word_count
925                    ));
926                    if !piece.audience.is_empty() {
927                        md.push_str(&format!("**Audience:** {}\n\n", piece.audience));
928                    }
929                    if !piece.tone.is_empty() {
930                        md.push_str(&format!("**Tone:** {}\n\n", piece.tone));
931                    }
932                    if !piece.tags.is_empty() {
933                        md.push_str(&format!("**Tags:** {}\n\n", piece.tags.join(", ")));
934                    }
935                    if !piece.body.is_empty() {
936                        md.push_str(&format!("{}\n\n", piece.body));
937                    }
938                    md.push_str("---\n\n");
939                }
940
941                Ok(ToolOutput::text(format!(
942                    "Exported {} piece(s) as Markdown:\n\n{}",
943                    filtered.len(),
944                    md
945                )))
946            }
947
948            _ => Ok(ToolOutput::text(format!(
949                "Unknown action: '{}'. Use: create, update, set_status, get, list, search, delete, schedule, calendar_add, calendar_list, calendar_remove, stats, adapt, export_markdown.",
950                action
951            ))),
952        }
953    }
954}
955
956#[cfg(test)]
957mod tests {
958    use super::*;
959    use tempfile::TempDir;
960
961    fn make_tool() -> (TempDir, ContentEngineTool) {
962        let dir = TempDir::new().unwrap();
963        let workspace = dir.path().canonicalize().unwrap();
964        let tool = ContentEngineTool::new(workspace);
965        (dir, tool)
966    }
967
968    #[test]
969    fn test_tool_properties() {
970        let (_dir, tool) = make_tool();
971        assert_eq!(tool.name(), "content_engine");
972        assert!(tool.description().contains("content pipeline"));
973        assert_eq!(tool.risk_level(), RiskLevel::Write);
974        assert_eq!(tool.timeout(), std::time::Duration::from_secs(30));
975    }
976
977    #[test]
978    fn test_schema_validation() {
979        let (_dir, tool) = make_tool();
980        let schema = tool.parameters_schema();
981        assert!(schema.get("properties").is_some());
982        let action_enum = &schema["properties"]["action"]["enum"];
983        assert!(action_enum.is_array());
984        let actions: Vec<&str> = action_enum
985            .as_array()
986            .unwrap()
987            .iter()
988            .map(|v| v.as_str().unwrap())
989            .collect();
990        assert!(actions.contains(&"create"));
991        assert!(actions.contains(&"update"));
992        assert!(actions.contains(&"set_status"));
993        assert!(actions.contains(&"get"));
994        assert!(actions.contains(&"list"));
995        assert!(actions.contains(&"search"));
996        assert!(actions.contains(&"delete"));
997        assert!(actions.contains(&"schedule"));
998        assert!(actions.contains(&"calendar_add"));
999        assert!(actions.contains(&"calendar_list"));
1000        assert!(actions.contains(&"calendar_remove"));
1001        assert!(actions.contains(&"stats"));
1002        assert!(actions.contains(&"adapt"));
1003        assert!(actions.contains(&"export_markdown"));
1004        assert_eq!(actions.len(), 14);
1005    }
1006
1007    #[tokio::test]
1008    async fn test_create_idea() {
1009        let (_dir, tool) = make_tool();
1010        let result = tool
1011            .execute(json!({"action": "create", "title": "AI trends"}))
1012            .await
1013            .unwrap();
1014        assert!(result.content.contains("Created content #1"));
1015        assert!(result.content.contains("AI trends"));
1016        assert!(result.content.contains("Idea"));
1017    }
1018
1019    #[tokio::test]
1020    async fn test_create_draft() {
1021        let (_dir, tool) = make_tool();
1022        let result = tool
1023            .execute(json!({
1024                "action": "create",
1025                "title": "Rust ownership guide",
1026                "body": "Ownership is a set of rules that govern memory management.",
1027                "platform": "blog",
1028                "tags": ["rust", "programming"]
1029            }))
1030            .await
1031            .unwrap();
1032        assert!(result.content.contains("Created content #1"));
1033        assert!(result.content.contains("Draft"));
1034
1035        // Verify word count was computed
1036        let get_result = tool
1037            .execute(json!({"action": "get", "id": 1}))
1038            .await
1039            .unwrap();
1040        assert!(get_result.content.contains("Words:    10"));
1041        assert!(get_result.content.contains("rust, programming"));
1042    }
1043
1044    #[tokio::test]
1045    async fn test_update_body_recomputes_word_count() {
1046        let (_dir, tool) = make_tool();
1047        tool.execute(json!({
1048            "action": "create",
1049            "title": "Article",
1050            "body": "one two three"
1051        }))
1052        .await
1053        .unwrap();
1054
1055        // Check initial word count
1056        let get1 = tool
1057            .execute(json!({"action": "get", "id": 1}))
1058            .await
1059            .unwrap();
1060        assert!(get1.content.contains("Words:    3"));
1061
1062        // Update body
1063        tool.execute(json!({
1064            "action": "update",
1065            "id": 1,
1066            "body": "one two three four five six"
1067        }))
1068        .await
1069        .unwrap();
1070
1071        let get2 = tool
1072            .execute(json!({"action": "get", "id": 1}))
1073            .await
1074            .unwrap();
1075        assert!(get2.content.contains("Words:    6"));
1076    }
1077
1078    #[tokio::test]
1079    async fn test_status_lifecycle() {
1080        let (_dir, tool) = make_tool();
1081        tool.execute(json!({
1082            "action": "create",
1083            "title": "Post",
1084            "body": "Some content here."
1085        }))
1086        .await
1087        .unwrap();
1088
1089        // Draft -> Review
1090        let r = tool
1091            .execute(json!({"action": "set_status", "id": 1, "status": "review"}))
1092            .await
1093            .unwrap();
1094        assert!(r.content.contains("Review"));
1095
1096        // Review -> Scheduled
1097        let r = tool
1098            .execute(json!({"action": "set_status", "id": 1, "status": "scheduled"}))
1099            .await
1100            .unwrap();
1101        assert!(r.content.contains("Scheduled"));
1102
1103        // Scheduled -> Published (should set published_at)
1104        let r = tool
1105            .execute(json!({"action": "set_status", "id": 1, "status": "published"}))
1106            .await
1107            .unwrap();
1108        assert!(r.content.contains("Published"));
1109
1110        let detail = tool
1111            .execute(json!({"action": "get", "id": 1}))
1112            .await
1113            .unwrap();
1114        assert!(detail.content.contains("Published:"));
1115    }
1116
1117    #[tokio::test]
1118    async fn test_search_across_fields() {
1119        let (_dir, tool) = make_tool();
1120        tool.execute(json!({
1121            "action": "create",
1122            "title": "Kubernetes basics",
1123            "body": "Learn about pods and deployments.",
1124            "tags": ["devops", "containers"]
1125        }))
1126        .await
1127        .unwrap();
1128        tool.execute(json!({
1129            "action": "create",
1130            "title": "Cooking pasta",
1131            "body": "Boil water and add salt."
1132        }))
1133        .await
1134        .unwrap();
1135
1136        // Search by title
1137        let r = tool
1138            .execute(json!({"action": "search", "query": "kubernetes"}))
1139            .await
1140            .unwrap();
1141        assert!(r.content.contains("Kubernetes basics"));
1142        assert!(!r.content.contains("Cooking"));
1143
1144        // Search by body
1145        let r = tool
1146            .execute(json!({"action": "search", "query": "pods"}))
1147            .await
1148            .unwrap();
1149        assert!(r.content.contains("Kubernetes"));
1150
1151        // Search by tag
1152        let r = tool
1153            .execute(json!({"action": "search", "query": "devops"}))
1154            .await
1155            .unwrap();
1156        assert!(r.content.contains("Kubernetes"));
1157
1158        // No match
1159        let r = tool
1160            .execute(json!({"action": "search", "query": "zzznomatch"}))
1161            .await
1162            .unwrap();
1163        assert!(r.content.contains("No content matching"));
1164    }
1165
1166    #[tokio::test]
1167    async fn test_delete_cascades_calendar() {
1168        let (_dir, tool) = make_tool();
1169        tool.execute(json!({
1170            "action": "create",
1171            "title": "Blog post",
1172            "body": "Content body."
1173        }))
1174        .await
1175        .unwrap();
1176
1177        // Add a calendar entry linked to content #1
1178        tool.execute(json!({
1179            "action": "calendar_add",
1180            "date": "2026-03-15",
1181            "platform": "blog",
1182            "topic": "Publish blog post",
1183            "content_id": 1
1184        }))
1185        .await
1186        .unwrap();
1187
1188        // Verify calendar entry exists
1189        let cal = tool
1190            .execute(json!({"action": "calendar_list"}))
1191            .await
1192            .unwrap();
1193        assert!(cal.content.contains("Publish blog post"));
1194
1195        // Delete content #1 — should cascade to calendar
1196        let del = tool
1197            .execute(json!({"action": "delete", "id": 1}))
1198            .await
1199            .unwrap();
1200        assert!(del.content.contains("Deleted content #1"));
1201
1202        // Calendar should be empty
1203        let cal2 = tool
1204            .execute(json!({"action": "calendar_list"}))
1205            .await
1206            .unwrap();
1207        assert!(cal2.content.contains("No calendar entries"));
1208    }
1209
1210    #[tokio::test]
1211    async fn test_schedule_sets_status() {
1212        let (_dir, tool) = make_tool();
1213        tool.execute(json!({
1214            "action": "create",
1215            "title": "Scheduled post",
1216            "body": "Will go live soon."
1217        }))
1218        .await
1219        .unwrap();
1220
1221        let r = tool
1222            .execute(json!({
1223                "action": "schedule",
1224                "id": 1,
1225                "date": "2026-04-01",
1226                "time": "14:30"
1227            }))
1228            .await
1229            .unwrap();
1230        assert!(r.content.contains("scheduled for 2026-04-01 14:30"));
1231
1232        let detail = tool
1233            .execute(json!({"action": "get", "id": 1}))
1234            .await
1235            .unwrap();
1236        assert!(detail.content.contains("Status:   Scheduled"));
1237        assert!(detail.content.contains("Scheduled: 2026-04-01 14:30"));
1238    }
1239
1240    #[tokio::test]
1241    async fn test_calendar_crud() {
1242        let (_dir, tool) = make_tool();
1243
1244        // Add
1245        let r = tool
1246            .execute(json!({
1247                "action": "calendar_add",
1248                "date": "2026-03-01",
1249                "platform": "twitter",
1250                "topic": "Thread on Rust async"
1251            }))
1252            .await
1253            .unwrap();
1254        assert!(r.content.contains("Added calendar entry"));
1255
1256        // List
1257        let r = tool
1258            .execute(json!({"action": "calendar_list"}))
1259            .await
1260            .unwrap();
1261        assert!(r.content.contains("Thread on Rust async"));
1262        assert!(r.content.contains("Twitter"));
1263        assert!(r.content.contains("2026-03-01"));
1264
1265        // Remove
1266        let r = tool
1267            .execute(json!({
1268                "action": "calendar_remove",
1269                "date": "2026-03-01",
1270                "platform": "twitter"
1271            }))
1272            .await
1273            .unwrap();
1274        assert!(r.content.contains("Removed"));
1275
1276        // Verify empty
1277        let r = tool
1278            .execute(json!({"action": "calendar_list"}))
1279            .await
1280            .unwrap();
1281        assert!(r.content.contains("No calendar entries"));
1282    }
1283
1284    #[tokio::test]
1285    async fn test_calendar_list_filter_month() {
1286        let (_dir, tool) = make_tool();
1287
1288        tool.execute(json!({
1289            "action": "calendar_add",
1290            "date": "2026-03-01",
1291            "platform": "blog",
1292            "topic": "March post"
1293        }))
1294        .await
1295        .unwrap();
1296        tool.execute(json!({
1297            "action": "calendar_add",
1298            "date": "2026-04-15",
1299            "platform": "blog",
1300            "topic": "April post"
1301        }))
1302        .await
1303        .unwrap();
1304
1305        // Filter by month
1306        let r = tool
1307            .execute(json!({"action": "calendar_list", "month": "2026-03"}))
1308            .await
1309            .unwrap();
1310        assert!(r.content.contains("March post"));
1311        assert!(!r.content.contains("April post"));
1312
1313        // Different month
1314        let r = tool
1315            .execute(json!({"action": "calendar_list", "month": "2026-04"}))
1316            .await
1317            .unwrap();
1318        assert!(r.content.contains("April post"));
1319        assert!(!r.content.contains("March post"));
1320    }
1321
1322    #[tokio::test]
1323    async fn test_stats_counts() {
1324        let (_dir, tool) = make_tool();
1325
1326        tool.execute(json!({
1327            "action": "create",
1328            "title": "Post A",
1329            "body": "word1 word2 word3",
1330            "platform": "blog"
1331        }))
1332        .await
1333        .unwrap();
1334        tool.execute(json!({
1335            "action": "create",
1336            "title": "Tweet B",
1337            "body": "short tweet",
1338            "platform": "twitter"
1339        }))
1340        .await
1341        .unwrap();
1342        tool.execute(json!({
1343            "action": "create",
1344            "title": "Idea C"
1345        }))
1346        .await
1347        .unwrap();
1348
1349        let r = tool.execute(json!({"action": "stats"})).await.unwrap();
1350        assert!(r.content.contains("Total pieces: 3"));
1351        assert!(r.content.contains("Total words:  5"));
1352        // Post A (blog) + Idea C (defaults to blog) = 2 Blog
1353        assert!(r.content.contains("Blog: 2"));
1354        assert!(r.content.contains("Twitter: 1"));
1355        assert!(r.content.contains("Draft: 2"));
1356        assert!(r.content.contains("Idea: 1"));
1357    }
1358
1359    #[tokio::test]
1360    async fn test_adapt_twitter_constraints() {
1361        let (_dir, tool) = make_tool();
1362
1363        tool.execute(json!({
1364            "action": "create",
1365            "title": "Big blog post",
1366            "body": "This is a long form blog post about Rust programming language.",
1367            "platform": "blog"
1368        }))
1369        .await
1370        .unwrap();
1371
1372        let r = tool
1373            .execute(json!({
1374                "action": "adapt",
1375                "id": 1,
1376                "target_platform": "twitter"
1377            }))
1378            .await
1379            .unwrap();
1380        assert!(r.content.contains("280 char"));
1381        assert!(r.content.contains("Twitter"));
1382        assert!(r.content.contains("Big blog post"));
1383    }
1384
1385    #[tokio::test]
1386    async fn test_adapt_linkedin_constraints() {
1387        let (_dir, tool) = make_tool();
1388
1389        tool.execute(json!({
1390            "action": "create",
1391            "title": "Tech article",
1392            "body": "Technical content about distributed systems.",
1393            "platform": "blog"
1394        }))
1395        .await
1396        .unwrap();
1397
1398        let r = tool
1399            .execute(json!({
1400                "action": "adapt",
1401                "id": 1,
1402                "target_platform": "linkedin",
1403                "target_tone": "thought-leadership"
1404            }))
1405            .await
1406            .unwrap();
1407        assert!(r.content.contains("rofessional")); // "Professional" case-insensitive partial
1408        assert!(r.content.contains("LinkedIn"));
1409        assert!(r.content.contains("thought-leadership"));
1410    }
1411
1412    #[tokio::test]
1413    async fn test_export_markdown() {
1414        let (_dir, tool) = make_tool();
1415
1416        tool.execute(json!({
1417            "action": "create",
1418            "title": "Markdown test",
1419            "body": "Export this content.",
1420            "platform": "medium",
1421            "audience": "developers",
1422            "tone": "casual",
1423            "tags": ["test", "export"]
1424        }))
1425        .await
1426        .unwrap();
1427
1428        let r = tool
1429            .execute(json!({"action": "export_markdown", "id": 1}))
1430            .await
1431            .unwrap();
1432        assert!(r.content.contains("# Markdown test"));
1433        assert!(r.content.contains("**Platform:** Medium"));
1434        assert!(r.content.contains("**Status:** Draft"));
1435        assert!(r.content.contains("**Audience:** developers"));
1436        assert!(r.content.contains("**Tone:** casual"));
1437        assert!(r.content.contains("**Tags:** test, export"));
1438        assert!(r.content.contains("Export this content."));
1439    }
1440
1441    #[tokio::test]
1442    async fn test_list_filter_platform() {
1443        let (_dir, tool) = make_tool();
1444
1445        tool.execute(json!({
1446            "action": "create",
1447            "title": "Blog A",
1448            "body": "body",
1449            "platform": "blog"
1450        }))
1451        .await
1452        .unwrap();
1453        tool.execute(json!({
1454            "action": "create",
1455            "title": "Tweet B",
1456            "body": "body",
1457            "platform": "twitter"
1458        }))
1459        .await
1460        .unwrap();
1461
1462        let r = tool
1463            .execute(json!({"action": "list", "platform": "blog"}))
1464            .await
1465            .unwrap();
1466        assert!(r.content.contains("Blog A"));
1467        assert!(!r.content.contains("Tweet B"));
1468    }
1469
1470    #[tokio::test]
1471    async fn test_list_filter_status() {
1472        let (_dir, tool) = make_tool();
1473
1474        tool.execute(json!({
1475            "action": "create",
1476            "title": "Draft piece",
1477            "body": "has body"
1478        }))
1479        .await
1480        .unwrap();
1481        tool.execute(json!({
1482            "action": "create",
1483            "title": "Idea piece"
1484        }))
1485        .await
1486        .unwrap();
1487
1488        let r = tool
1489            .execute(json!({"action": "list", "status": "idea"}))
1490            .await
1491            .unwrap();
1492        assert!(r.content.contains("Idea piece"));
1493        assert!(!r.content.contains("Draft piece"));
1494
1495        let r = tool
1496            .execute(json!({"action": "list", "status": "draft"}))
1497            .await
1498            .unwrap();
1499        assert!(r.content.contains("Draft piece"));
1500        assert!(!r.content.contains("Idea piece"));
1501    }
1502
1503    #[tokio::test]
1504    async fn test_state_roundtrip() {
1505        let (_dir, tool) = make_tool();
1506
1507        // Create some state
1508        tool.execute(json!({
1509            "action": "create",
1510            "title": "Persisted",
1511            "body": "Body text here.",
1512            "platform": "github",
1513            "tags": ["persist"]
1514        }))
1515        .await
1516        .unwrap();
1517        tool.execute(json!({
1518            "action": "calendar_add",
1519            "date": "2026-06-01",
1520            "platform": "github",
1521            "topic": "Release notes"
1522        }))
1523        .await
1524        .unwrap();
1525
1526        // Load raw state and verify roundtrip
1527        let state = tool.load_state();
1528        assert_eq!(state.pieces.len(), 1);
1529        assert_eq!(state.pieces[0].title, "Persisted");
1530        assert_eq!(state.pieces[0].platform, ContentPlatform::GitHub);
1531        assert_eq!(state.pieces[0].word_count, 3);
1532        assert_eq!(state.calendar.len(), 1);
1533        assert_eq!(state.calendar[0].topic, "Release notes");
1534        assert_eq!(state.next_id, 2);
1535
1536        // Save and reload
1537        tool.save_state(&state).unwrap();
1538        let reloaded = tool.load_state();
1539        assert_eq!(reloaded.pieces.len(), 1);
1540        assert_eq!(reloaded.calendar.len(), 1);
1541        assert_eq!(reloaded.next_id, 2);
1542    }
1543
1544    #[tokio::test]
1545    async fn test_unknown_action() {
1546        let (_dir, tool) = make_tool();
1547        let r = tool.execute(json!({"action": "foobar"})).await.unwrap();
1548        assert!(r.content.contains("Unknown action"));
1549        assert!(r.content.contains("foobar"));
1550    }
1551}