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::{Value, json};
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 =
352                    ContentPlatform::from_str_loose(platform_str).unwrap_or(ContentPlatform::Blog);
353                let body = args
354                    .get("body")
355                    .and_then(|v| v.as_str())
356                    .unwrap_or("")
357                    .to_string();
358                let audience = args
359                    .get("audience")
360                    .and_then(|v| v.as_str())
361                    .unwrap_or("")
362                    .to_string();
363                let tone = args
364                    .get("tone")
365                    .and_then(|v| v.as_str())
366                    .unwrap_or("")
367                    .to_string();
368                let tags: Vec<String> = args
369                    .get("tags")
370                    .and_then(|v| v.as_array())
371                    .map(|arr| {
372                        arr.iter()
373                            .filter_map(|v| v.as_str().map(String::from))
374                            .collect()
375                    })
376                    .unwrap_or_default();
377
378                let word_count = count_words(&body);
379                let status = if body.is_empty() {
380                    ContentStatus::Idea
381                } else {
382                    ContentStatus::Draft
383                };
384
385                let id = state.next_id;
386                state.next_id += 1;
387                let now = Utc::now();
388                state.pieces.push(ContentPiece {
389                    id,
390                    title: title.to_string(),
391                    body,
392                    platform: platform.clone(),
393                    status: status.clone(),
394                    audience,
395                    tone,
396                    tags,
397                    word_count,
398                    created_at: now,
399                    updated_at: now,
400                    scheduled_for: None,
401                    published_at: None,
402                });
403                self.save_state(&state)?;
404
405                Ok(ToolOutput::text(format!(
406                    "Created content #{} '{}' ({}, {}).",
407                    id, title, platform, status
408                )))
409            }
410
411            "update" => {
412                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
413                let idx = match Self::find_piece(&state.pieces, id) {
414                    Some(i) => i,
415                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
416                };
417
418                if let Some(title) = args.get("title").and_then(|v| v.as_str()) {
419                    state.pieces[idx].title = title.to_string();
420                }
421                if let Some(body) = args.get("body").and_then(|v| v.as_str()) {
422                    state.pieces[idx].body = body.to_string();
423                    state.pieces[idx].word_count = count_words(body);
424                }
425                if let Some(platform_str) = args.get("platform").and_then(|v| v.as_str())
426                    && let Some(p) = ContentPlatform::from_str_loose(platform_str)
427                {
428                    state.pieces[idx].platform = p;
429                }
430                if let Some(audience) = args.get("audience").and_then(|v| v.as_str()) {
431                    state.pieces[idx].audience = audience.to_string();
432                }
433                if let Some(tone) = args.get("tone").and_then(|v| v.as_str()) {
434                    state.pieces[idx].tone = tone.to_string();
435                }
436                state.pieces[idx].updated_at = Utc::now();
437                self.save_state(&state)?;
438
439                Ok(ToolOutput::text(format!(
440                    "Updated content #{} '{}'.",
441                    id, state.pieces[idx].title
442                )))
443            }
444
445            "set_status" => {
446                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
447                let status_str = args.get("status").and_then(|v| v.as_str()).unwrap_or("");
448                let new_status = match ContentStatus::from_str_loose(status_str) {
449                    Some(s) => s,
450                    None => {
451                        return Ok(ToolOutput::text(format!(
452                            "Unknown status '{}'. Use: idea, draft, review, scheduled, published, archived.",
453                            status_str
454                        )));
455                    }
456                };
457                let idx = match Self::find_piece(&state.pieces, id) {
458                    Some(i) => i,
459                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
460                };
461
462                state.pieces[idx].status = new_status.clone();
463                state.pieces[idx].updated_at = Utc::now();
464                if new_status == ContentStatus::Published {
465                    state.pieces[idx].published_at = Some(Utc::now());
466                }
467                self.save_state(&state)?;
468
469                Ok(ToolOutput::text(format!(
470                    "Content #{} status set to {}.",
471                    id, new_status
472                )))
473            }
474
475            "get" => {
476                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
477                match Self::find_piece(&state.pieces, id) {
478                    Some(idx) => Ok(ToolOutput::text(Self::format_piece_detail(
479                        &state.pieces[idx],
480                    ))),
481                    None => Ok(ToolOutput::text(format!("Content #{} not found.", id))),
482                }
483            }
484
485            "list" => {
486                let platform_filter = args
487                    .get("platform")
488                    .and_then(|v| v.as_str())
489                    .and_then(ContentPlatform::from_str_loose);
490                let status_filter = args
491                    .get("status")
492                    .and_then(|v| v.as_str())
493                    .and_then(ContentStatus::from_str_loose);
494                let tag_filter = args.get("tag").and_then(|v| v.as_str());
495
496                let filtered: Vec<&ContentPiece> = state
497                    .pieces
498                    .iter()
499                    .filter(|p| {
500                        platform_filter
501                            .as_ref()
502                            .map(|pf| p.platform == *pf)
503                            .unwrap_or(true)
504                    })
505                    .filter(|p| {
506                        status_filter
507                            .as_ref()
508                            .map(|sf| p.status == *sf)
509                            .unwrap_or(true)
510                    })
511                    .filter(|p| {
512                        tag_filter
513                            .map(|t| p.tags.iter().any(|tag| tag.eq_ignore_ascii_case(t)))
514                            .unwrap_or(true)
515                    })
516                    .collect();
517
518                if filtered.is_empty() {
519                    return Ok(ToolOutput::text("No content pieces found."));
520                }
521
522                let lines: Vec<String> = filtered
523                    .into_iter()
524                    .map(Self::format_piece_summary)
525                    .collect();
526                Ok(ToolOutput::text(format!(
527                    "Content ({} pieces):\n{}",
528                    lines.len(),
529                    lines.join("\n")
530                )))
531            }
532
533            "search" => {
534                let query = args
535                    .get("query")
536                    .and_then(|v| v.as_str())
537                    .unwrap_or("")
538                    .to_lowercase();
539                if query.is_empty() {
540                    return Ok(ToolOutput::text("Please provide a search query."));
541                }
542
543                let matches: Vec<String> = state
544                    .pieces
545                    .iter()
546                    .filter(|p| {
547                        p.title.to_lowercase().contains(&query)
548                            || p.body.to_lowercase().contains(&query)
549                            || p.tags.iter().any(|t| t.to_lowercase().contains(&query))
550                    })
551                    .map(Self::format_piece_summary)
552                    .collect();
553
554                if matches.is_empty() {
555                    Ok(ToolOutput::text(format!(
556                        "No content matching '{}'.",
557                        query
558                    )))
559                } else {
560                    Ok(ToolOutput::text(format!(
561                        "Found {} pieces:\n{}",
562                        matches.len(),
563                        matches.join("\n")
564                    )))
565                }
566            }
567
568            "delete" => {
569                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
570                let idx = match Self::find_piece(&state.pieces, id) {
571                    Some(i) => i,
572                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
573                };
574                let title = state.pieces[idx].title.clone();
575                state.pieces.remove(idx);
576                // Remove linked calendar entries
577                state.calendar.retain(|c| c.content_id != Some(id));
578                self.save_state(&state)?;
579
580                Ok(ToolOutput::text(format!(
581                    "Deleted content #{} '{}' and linked calendar entries.",
582                    id, title
583                )))
584            }
585
586            "schedule" => {
587                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
588                let date_str = args.get("date").and_then(|v| v.as_str()).unwrap_or("");
589                if date_str.is_empty() {
590                    return Ok(ToolOutput::text(
591                        "Please provide a date in YYYY-MM-DD format.",
592                    ));
593                }
594                let time_str = args.get("time").and_then(|v| v.as_str()).unwrap_or("09:00");
595
596                let datetime_str = format!("{}T{}:00Z", date_str, time_str);
597                let scheduled_dt = datetime_str.parse::<DateTime<Utc>>().map_err(|e| {
598                    ToolError::ExecutionFailed {
599                        name: "content_engine".to_string(),
600                        message: format!("Invalid date/time '{}': {}", datetime_str, e),
601                    }
602                })?;
603
604                let idx = match Self::find_piece(&state.pieces, id) {
605                    Some(i) => i,
606                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
607                };
608
609                state.pieces[idx].status = ContentStatus::Scheduled;
610                state.pieces[idx].scheduled_for = Some(scheduled_dt);
611                state.pieces[idx].updated_at = Utc::now();
612                self.save_state(&state)?;
613
614                Ok(ToolOutput::text(format!(
615                    "Content #{} '{}' scheduled for {} {}.",
616                    id, state.pieces[idx].title, date_str, time_str
617                )))
618            }
619
620            "calendar_add" => {
621                let date = args
622                    .get("date")
623                    .and_then(|v| v.as_str())
624                    .unwrap_or("")
625                    .to_string();
626                if date.is_empty() {
627                    return Ok(ToolOutput::text(
628                        "Please provide a date in YYYY-MM-DD format.",
629                    ));
630                }
631                let platform_str = args
632                    .get("platform")
633                    .and_then(|v| v.as_str())
634                    .unwrap_or("blog");
635                let platform =
636                    ContentPlatform::from_str_loose(platform_str).unwrap_or(ContentPlatform::Blog);
637                let topic = args
638                    .get("topic")
639                    .and_then(|v| v.as_str())
640                    .unwrap_or("")
641                    .to_string();
642                if topic.is_empty() {
643                    return Ok(ToolOutput::text("Please provide a topic."));
644                }
645                let content_id = args
646                    .get("content_id")
647                    .and_then(|v| v.as_u64())
648                    .map(|v| v as usize);
649                let notes = args
650                    .get("notes")
651                    .and_then(|v| v.as_str())
652                    .unwrap_or("")
653                    .to_string();
654
655                state.calendar.push(CalendarEntry {
656                    date: date.clone(),
657                    platform: platform.clone(),
658                    topic: topic.clone(),
659                    content_id,
660                    notes,
661                });
662                self.save_state(&state)?;
663
664                Ok(ToolOutput::text(format!(
665                    "Added calendar entry: {} on {} ({}).",
666                    topic, date, platform
667                )))
668            }
669
670            "calendar_list" => {
671                let month_filter = args.get("month").and_then(|v| v.as_str());
672                let platform_filter = args
673                    .get("platform")
674                    .and_then(|v| v.as_str())
675                    .and_then(ContentPlatform::from_str_loose);
676
677                let filtered: Vec<&CalendarEntry> = state
678                    .calendar
679                    .iter()
680                    .filter(|c| month_filter.map(|m| c.date.starts_with(m)).unwrap_or(true))
681                    .filter(|c| {
682                        platform_filter
683                            .as_ref()
684                            .map(|pf| c.platform == *pf)
685                            .unwrap_or(true)
686                    })
687                    .collect();
688
689                if filtered.is_empty() {
690                    return Ok(ToolOutput::text("No calendar entries found."));
691                }
692
693                let lines: Vec<String> = filtered
694                    .iter()
695                    .map(|c| {
696                        let linked = c
697                            .content_id
698                            .map(|id| format!(" (content #{})", id))
699                            .unwrap_or_default();
700                        let notes = if c.notes.is_empty() {
701                            String::new()
702                        } else {
703                            format!(" — {}", c.notes)
704                        };
705                        format!(
706                            "  {} | {} | {}{}{}",
707                            c.date, c.platform, c.topic, linked, notes
708                        )
709                    })
710                    .collect();
711
712                Ok(ToolOutput::text(format!(
713                    "Content calendar ({} entries):\n{}",
714                    filtered.len(),
715                    lines.join("\n")
716                )))
717            }
718
719            "calendar_remove" => {
720                let date = args.get("date").and_then(|v| v.as_str()).unwrap_or("");
721                let platform_str = args.get("platform").and_then(|v| v.as_str()).unwrap_or("");
722                let platform = match ContentPlatform::from_str_loose(platform_str) {
723                    Some(p) => p,
724                    None => {
725                        return Ok(ToolOutput::text(format!(
726                            "Unknown platform '{}'. Use: blog, twitter, linkedin, github, medium, newsletter.",
727                            platform_str
728                        )));
729                    }
730                };
731
732                let before = state.calendar.len();
733                state
734                    .calendar
735                    .retain(|c| !(c.date == date && c.platform == platform));
736                let removed = before - state.calendar.len();
737
738                if removed == 0 {
739                    return Ok(ToolOutput::text(format!(
740                        "No calendar entry found for {} on {}.",
741                        platform, date
742                    )));
743                }
744
745                self.save_state(&state)?;
746                Ok(ToolOutput::text(format!(
747                    "Removed {} calendar entry/entries for {} on {}.",
748                    removed, platform, date
749                )))
750            }
751
752            "stats" => {
753                if state.pieces.is_empty() {
754                    return Ok(ToolOutput::text("No content pieces yet."));
755                }
756
757                // Counts by status
758                let mut by_status: std::collections::HashMap<String, usize> =
759                    std::collections::HashMap::new();
760                for p in &state.pieces {
761                    *by_status.entry(p.status.as_str().to_string()).or_insert(0) += 1;
762                }
763
764                // Counts by platform
765                let mut by_platform: std::collections::HashMap<String, usize> =
766                    std::collections::HashMap::new();
767                for p in &state.pieces {
768                    *by_platform
769                        .entry(p.platform.as_str().to_string())
770                        .or_insert(0) += 1;
771                }
772
773                // Upcoming scheduled
774                let now = Utc::now();
775                let upcoming: Vec<&ContentPiece> = state
776                    .pieces
777                    .iter()
778                    .filter(|p| {
779                        p.status == ContentStatus::Scheduled
780                            && p.scheduled_for.map(|s| s > now).unwrap_or(false)
781                    })
782                    .collect();
783
784                // Total word count
785                let total_words: usize = state.pieces.iter().map(|p| p.word_count).sum();
786
787                let mut out = String::from("Content stats:\n");
788                out.push_str(&format!("  Total pieces: {}\n", state.pieces.len()));
789                out.push_str(&format!("  Total words:  {}\n\n", total_words));
790
791                out.push_str("  By status:\n");
792                let mut status_entries: Vec<_> = by_status.iter().collect();
793                status_entries.sort_by_key(|(k, _)| (*k).clone());
794                for (status, count) in &status_entries {
795                    out.push_str(&format!("    {}: {}\n", status, count));
796                }
797
798                out.push_str("\n  By platform:\n");
799                let mut platform_entries: Vec<_> = by_platform.iter().collect();
800                platform_entries.sort_by_key(|(k, _)| (*k).clone());
801                for (platform, count) in &platform_entries {
802                    out.push_str(&format!("    {}: {}\n", platform, count));
803                }
804
805                if !upcoming.is_empty() {
806                    out.push_str(&format!("\n  Upcoming scheduled: {}\n", upcoming.len()));
807                    for p in &upcoming {
808                        let date = p
809                            .scheduled_for
810                            .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
811                            .unwrap_or_default();
812                        out.push_str(&format!("    #{} '{}' — {}\n", p.id, p.title, date));
813                    }
814                }
815
816                Ok(ToolOutput::text(out))
817            }
818
819            "adapt" => {
820                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
821                let target_str = args
822                    .get("target_platform")
823                    .and_then(|v| v.as_str())
824                    .unwrap_or("");
825                let target_platform = match ContentPlatform::from_str_loose(target_str) {
826                    Some(p) => p,
827                    None => {
828                        return Ok(ToolOutput::text(format!(
829                            "Unknown target platform '{}'. Use: blog, twitter, linkedin, github, medium, newsletter.",
830                            target_str
831                        )));
832                    }
833                };
834                let target_tone = args
835                    .get("target_tone")
836                    .and_then(|v| v.as_str())
837                    .unwrap_or("");
838
839                let idx = match Self::find_piece(&state.pieces, id) {
840                    Some(i) => i,
841                    None => return Ok(ToolOutput::text(format!("Content #{} not found.", id))),
842                };
843
844                let piece = &state.pieces[idx];
845                let constraints = Self::platform_constraints(&target_platform);
846
847                let mut prompt = String::new();
848                prompt.push_str(&format!(
849                    "Adapt the following content for {}.\n\n",
850                    target_platform
851                ));
852                prompt.push_str(&format!("Platform constraints:\n{}\n\n", constraints));
853                if !target_tone.is_empty() {
854                    prompt.push_str(&format!("Target tone: {}\n\n", target_tone));
855                } else if !piece.tone.is_empty() {
856                    prompt.push_str(&format!("Original tone: {}\n\n", piece.tone));
857                }
858                if !piece.audience.is_empty() {
859                    prompt.push_str(&format!("Target audience: {}\n\n", piece.audience));
860                }
861                prompt.push_str(&format!("Original title: {}\n", piece.title));
862                prompt.push_str(&format!("Original platform: {}\n\n", piece.platform));
863                prompt.push_str(&format!("Original content:\n{}\n", piece.body));
864
865                Ok(ToolOutput::text(format!(
866                    "Adaptation prompt for #{} → {}:\n\n{}",
867                    id, target_platform, prompt
868                )))
869            }
870
871            "export_markdown" => {
872                let id_filter = args.get("id").and_then(|v| v.as_u64()).map(|v| v as usize);
873                let status_filter = args
874                    .get("status")
875                    .and_then(|v| v.as_str())
876                    .and_then(ContentStatus::from_str_loose);
877
878                let filtered: Vec<&ContentPiece> = state
879                    .pieces
880                    .iter()
881                    .filter(|p| id_filter.map(|id| p.id == id).unwrap_or(true))
882                    .filter(|p| {
883                        status_filter
884                            .as_ref()
885                            .map(|sf| p.status == *sf)
886                            .unwrap_or(true)
887                    })
888                    .collect();
889
890                if filtered.is_empty() {
891                    return Ok(ToolOutput::text("No content to export."));
892                }
893
894                let mut md = String::new();
895                for piece in &filtered {
896                    md.push_str(&format!("# {}\n\n", piece.title));
897                    md.push_str(&format!(
898                        "**Platform:** {} | **Status:** {} | **Words:** {}\n\n",
899                        piece.platform, piece.status, piece.word_count
900                    ));
901                    if !piece.audience.is_empty() {
902                        md.push_str(&format!("**Audience:** {}\n\n", piece.audience));
903                    }
904                    if !piece.tone.is_empty() {
905                        md.push_str(&format!("**Tone:** {}\n\n", piece.tone));
906                    }
907                    if !piece.tags.is_empty() {
908                        md.push_str(&format!("**Tags:** {}\n\n", piece.tags.join(", ")));
909                    }
910                    if !piece.body.is_empty() {
911                        md.push_str(&format!("{}\n\n", piece.body));
912                    }
913                    md.push_str("---\n\n");
914                }
915
916                Ok(ToolOutput::text(format!(
917                    "Exported {} piece(s) as Markdown:\n\n{}",
918                    filtered.len(),
919                    md
920                )))
921            }
922
923            _ => Ok(ToolOutput::text(format!(
924                "Unknown action: '{}'. Use: create, update, set_status, get, list, search, delete, schedule, calendar_add, calendar_list, calendar_remove, stats, adapt, export_markdown.",
925                action
926            ))),
927        }
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use tempfile::TempDir;
935
936    fn make_tool() -> (TempDir, ContentEngineTool) {
937        let dir = TempDir::new().unwrap();
938        let workspace = dir.path().canonicalize().unwrap();
939        let tool = ContentEngineTool::new(workspace);
940        (dir, tool)
941    }
942
943    #[test]
944    fn test_tool_properties() {
945        let (_dir, tool) = make_tool();
946        assert_eq!(tool.name(), "content_engine");
947        assert!(tool.description().contains("content pipeline"));
948        assert_eq!(tool.risk_level(), RiskLevel::Write);
949        assert_eq!(tool.timeout(), std::time::Duration::from_secs(30));
950    }
951
952    #[test]
953    fn test_schema_validation() {
954        let (_dir, tool) = make_tool();
955        let schema = tool.parameters_schema();
956        assert!(schema.get("properties").is_some());
957        let action_enum = &schema["properties"]["action"]["enum"];
958        assert!(action_enum.is_array());
959        let actions: Vec<&str> = action_enum
960            .as_array()
961            .unwrap()
962            .iter()
963            .map(|v| v.as_str().unwrap())
964            .collect();
965        assert!(actions.contains(&"create"));
966        assert!(actions.contains(&"update"));
967        assert!(actions.contains(&"set_status"));
968        assert!(actions.contains(&"get"));
969        assert!(actions.contains(&"list"));
970        assert!(actions.contains(&"search"));
971        assert!(actions.contains(&"delete"));
972        assert!(actions.contains(&"schedule"));
973        assert!(actions.contains(&"calendar_add"));
974        assert!(actions.contains(&"calendar_list"));
975        assert!(actions.contains(&"calendar_remove"));
976        assert!(actions.contains(&"stats"));
977        assert!(actions.contains(&"adapt"));
978        assert!(actions.contains(&"export_markdown"));
979        assert_eq!(actions.len(), 14);
980    }
981
982    #[tokio::test]
983    async fn test_create_idea() {
984        let (_dir, tool) = make_tool();
985        let result = tool
986            .execute(json!({"action": "create", "title": "AI trends"}))
987            .await
988            .unwrap();
989        assert!(result.content.contains("Created content #1"));
990        assert!(result.content.contains("AI trends"));
991        assert!(result.content.contains("Idea"));
992    }
993
994    #[tokio::test]
995    async fn test_create_draft() {
996        let (_dir, tool) = make_tool();
997        let result = tool
998            .execute(json!({
999                "action": "create",
1000                "title": "Rust ownership guide",
1001                "body": "Ownership is a set of rules that govern memory management.",
1002                "platform": "blog",
1003                "tags": ["rust", "programming"]
1004            }))
1005            .await
1006            .unwrap();
1007        assert!(result.content.contains("Created content #1"));
1008        assert!(result.content.contains("Draft"));
1009
1010        // Verify word count was computed
1011        let get_result = tool
1012            .execute(json!({"action": "get", "id": 1}))
1013            .await
1014            .unwrap();
1015        assert!(get_result.content.contains("Words:    10"));
1016        assert!(get_result.content.contains("rust, programming"));
1017    }
1018
1019    #[tokio::test]
1020    async fn test_update_body_recomputes_word_count() {
1021        let (_dir, tool) = make_tool();
1022        tool.execute(json!({
1023            "action": "create",
1024            "title": "Article",
1025            "body": "one two three"
1026        }))
1027        .await
1028        .unwrap();
1029
1030        // Check initial word count
1031        let get1 = tool
1032            .execute(json!({"action": "get", "id": 1}))
1033            .await
1034            .unwrap();
1035        assert!(get1.content.contains("Words:    3"));
1036
1037        // Update body
1038        tool.execute(json!({
1039            "action": "update",
1040            "id": 1,
1041            "body": "one two three four five six"
1042        }))
1043        .await
1044        .unwrap();
1045
1046        let get2 = tool
1047            .execute(json!({"action": "get", "id": 1}))
1048            .await
1049            .unwrap();
1050        assert!(get2.content.contains("Words:    6"));
1051    }
1052
1053    #[tokio::test]
1054    async fn test_status_lifecycle() {
1055        let (_dir, tool) = make_tool();
1056        tool.execute(json!({
1057            "action": "create",
1058            "title": "Post",
1059            "body": "Some content here."
1060        }))
1061        .await
1062        .unwrap();
1063
1064        // Draft -> Review
1065        let r = tool
1066            .execute(json!({"action": "set_status", "id": 1, "status": "review"}))
1067            .await
1068            .unwrap();
1069        assert!(r.content.contains("Review"));
1070
1071        // Review -> Scheduled
1072        let r = tool
1073            .execute(json!({"action": "set_status", "id": 1, "status": "scheduled"}))
1074            .await
1075            .unwrap();
1076        assert!(r.content.contains("Scheduled"));
1077
1078        // Scheduled -> Published (should set published_at)
1079        let r = tool
1080            .execute(json!({"action": "set_status", "id": 1, "status": "published"}))
1081            .await
1082            .unwrap();
1083        assert!(r.content.contains("Published"));
1084
1085        let detail = tool
1086            .execute(json!({"action": "get", "id": 1}))
1087            .await
1088            .unwrap();
1089        assert!(detail.content.contains("Published:"));
1090    }
1091
1092    #[tokio::test]
1093    async fn test_search_across_fields() {
1094        let (_dir, tool) = make_tool();
1095        tool.execute(json!({
1096            "action": "create",
1097            "title": "Kubernetes basics",
1098            "body": "Learn about pods and deployments.",
1099            "tags": ["devops", "containers"]
1100        }))
1101        .await
1102        .unwrap();
1103        tool.execute(json!({
1104            "action": "create",
1105            "title": "Cooking pasta",
1106            "body": "Boil water and add salt."
1107        }))
1108        .await
1109        .unwrap();
1110
1111        // Search by title
1112        let r = tool
1113            .execute(json!({"action": "search", "query": "kubernetes"}))
1114            .await
1115            .unwrap();
1116        assert!(r.content.contains("Kubernetes basics"));
1117        assert!(!r.content.contains("Cooking"));
1118
1119        // Search by body
1120        let r = tool
1121            .execute(json!({"action": "search", "query": "pods"}))
1122            .await
1123            .unwrap();
1124        assert!(r.content.contains("Kubernetes"));
1125
1126        // Search by tag
1127        let r = tool
1128            .execute(json!({"action": "search", "query": "devops"}))
1129            .await
1130            .unwrap();
1131        assert!(r.content.contains("Kubernetes"));
1132
1133        // No match
1134        let r = tool
1135            .execute(json!({"action": "search", "query": "zzznomatch"}))
1136            .await
1137            .unwrap();
1138        assert!(r.content.contains("No content matching"));
1139    }
1140
1141    #[tokio::test]
1142    async fn test_delete_cascades_calendar() {
1143        let (_dir, tool) = make_tool();
1144        tool.execute(json!({
1145            "action": "create",
1146            "title": "Blog post",
1147            "body": "Content body."
1148        }))
1149        .await
1150        .unwrap();
1151
1152        // Add a calendar entry linked to content #1
1153        tool.execute(json!({
1154            "action": "calendar_add",
1155            "date": "2026-03-15",
1156            "platform": "blog",
1157            "topic": "Publish blog post",
1158            "content_id": 1
1159        }))
1160        .await
1161        .unwrap();
1162
1163        // Verify calendar entry exists
1164        let cal = tool
1165            .execute(json!({"action": "calendar_list"}))
1166            .await
1167            .unwrap();
1168        assert!(cal.content.contains("Publish blog post"));
1169
1170        // Delete content #1 — should cascade to calendar
1171        let del = tool
1172            .execute(json!({"action": "delete", "id": 1}))
1173            .await
1174            .unwrap();
1175        assert!(del.content.contains("Deleted content #1"));
1176
1177        // Calendar should be empty
1178        let cal2 = tool
1179            .execute(json!({"action": "calendar_list"}))
1180            .await
1181            .unwrap();
1182        assert!(cal2.content.contains("No calendar entries"));
1183    }
1184
1185    #[tokio::test]
1186    async fn test_schedule_sets_status() {
1187        let (_dir, tool) = make_tool();
1188        tool.execute(json!({
1189            "action": "create",
1190            "title": "Scheduled post",
1191            "body": "Will go live soon."
1192        }))
1193        .await
1194        .unwrap();
1195
1196        let r = tool
1197            .execute(json!({
1198                "action": "schedule",
1199                "id": 1,
1200                "date": "2026-04-01",
1201                "time": "14:30"
1202            }))
1203            .await
1204            .unwrap();
1205        assert!(r.content.contains("scheduled for 2026-04-01 14:30"));
1206
1207        let detail = tool
1208            .execute(json!({"action": "get", "id": 1}))
1209            .await
1210            .unwrap();
1211        assert!(detail.content.contains("Status:   Scheduled"));
1212        assert!(detail.content.contains("Scheduled: 2026-04-01 14:30"));
1213    }
1214
1215    #[tokio::test]
1216    async fn test_calendar_crud() {
1217        let (_dir, tool) = make_tool();
1218
1219        // Add
1220        let r = tool
1221            .execute(json!({
1222                "action": "calendar_add",
1223                "date": "2026-03-01",
1224                "platform": "twitter",
1225                "topic": "Thread on Rust async"
1226            }))
1227            .await
1228            .unwrap();
1229        assert!(r.content.contains("Added calendar entry"));
1230
1231        // List
1232        let r = tool
1233            .execute(json!({"action": "calendar_list"}))
1234            .await
1235            .unwrap();
1236        assert!(r.content.contains("Thread on Rust async"));
1237        assert!(r.content.contains("Twitter"));
1238        assert!(r.content.contains("2026-03-01"));
1239
1240        // Remove
1241        let r = tool
1242            .execute(json!({
1243                "action": "calendar_remove",
1244                "date": "2026-03-01",
1245                "platform": "twitter"
1246            }))
1247            .await
1248            .unwrap();
1249        assert!(r.content.contains("Removed"));
1250
1251        // Verify empty
1252        let r = tool
1253            .execute(json!({"action": "calendar_list"}))
1254            .await
1255            .unwrap();
1256        assert!(r.content.contains("No calendar entries"));
1257    }
1258
1259    #[tokio::test]
1260    async fn test_calendar_list_filter_month() {
1261        let (_dir, tool) = make_tool();
1262
1263        tool.execute(json!({
1264            "action": "calendar_add",
1265            "date": "2026-03-01",
1266            "platform": "blog",
1267            "topic": "March post"
1268        }))
1269        .await
1270        .unwrap();
1271        tool.execute(json!({
1272            "action": "calendar_add",
1273            "date": "2026-04-15",
1274            "platform": "blog",
1275            "topic": "April post"
1276        }))
1277        .await
1278        .unwrap();
1279
1280        // Filter by month
1281        let r = tool
1282            .execute(json!({"action": "calendar_list", "month": "2026-03"}))
1283            .await
1284            .unwrap();
1285        assert!(r.content.contains("March post"));
1286        assert!(!r.content.contains("April post"));
1287
1288        // Different month
1289        let r = tool
1290            .execute(json!({"action": "calendar_list", "month": "2026-04"}))
1291            .await
1292            .unwrap();
1293        assert!(r.content.contains("April post"));
1294        assert!(!r.content.contains("March post"));
1295    }
1296
1297    #[tokio::test]
1298    async fn test_stats_counts() {
1299        let (_dir, tool) = make_tool();
1300
1301        tool.execute(json!({
1302            "action": "create",
1303            "title": "Post A",
1304            "body": "word1 word2 word3",
1305            "platform": "blog"
1306        }))
1307        .await
1308        .unwrap();
1309        tool.execute(json!({
1310            "action": "create",
1311            "title": "Tweet B",
1312            "body": "short tweet",
1313            "platform": "twitter"
1314        }))
1315        .await
1316        .unwrap();
1317        tool.execute(json!({
1318            "action": "create",
1319            "title": "Idea C"
1320        }))
1321        .await
1322        .unwrap();
1323
1324        let r = tool.execute(json!({"action": "stats"})).await.unwrap();
1325        assert!(r.content.contains("Total pieces: 3"));
1326        assert!(r.content.contains("Total words:  5"));
1327        // Post A (blog) + Idea C (defaults to blog) = 2 Blog
1328        assert!(r.content.contains("Blog: 2"));
1329        assert!(r.content.contains("Twitter: 1"));
1330        assert!(r.content.contains("Draft: 2"));
1331        assert!(r.content.contains("Idea: 1"));
1332    }
1333
1334    #[tokio::test]
1335    async fn test_adapt_twitter_constraints() {
1336        let (_dir, tool) = make_tool();
1337
1338        tool.execute(json!({
1339            "action": "create",
1340            "title": "Big blog post",
1341            "body": "This is a long form blog post about Rust programming language.",
1342            "platform": "blog"
1343        }))
1344        .await
1345        .unwrap();
1346
1347        let r = tool
1348            .execute(json!({
1349                "action": "adapt",
1350                "id": 1,
1351                "target_platform": "twitter"
1352            }))
1353            .await
1354            .unwrap();
1355        assert!(r.content.contains("280 char"));
1356        assert!(r.content.contains("Twitter"));
1357        assert!(r.content.contains("Big blog post"));
1358    }
1359
1360    #[tokio::test]
1361    async fn test_adapt_linkedin_constraints() {
1362        let (_dir, tool) = make_tool();
1363
1364        tool.execute(json!({
1365            "action": "create",
1366            "title": "Tech article",
1367            "body": "Technical content about distributed systems.",
1368            "platform": "blog"
1369        }))
1370        .await
1371        .unwrap();
1372
1373        let r = tool
1374            .execute(json!({
1375                "action": "adapt",
1376                "id": 1,
1377                "target_platform": "linkedin",
1378                "target_tone": "thought-leadership"
1379            }))
1380            .await
1381            .unwrap();
1382        assert!(r.content.contains("rofessional")); // "Professional" case-insensitive partial
1383        assert!(r.content.contains("LinkedIn"));
1384        assert!(r.content.contains("thought-leadership"));
1385    }
1386
1387    #[tokio::test]
1388    async fn test_export_markdown() {
1389        let (_dir, tool) = make_tool();
1390
1391        tool.execute(json!({
1392            "action": "create",
1393            "title": "Markdown test",
1394            "body": "Export this content.",
1395            "platform": "medium",
1396            "audience": "developers",
1397            "tone": "casual",
1398            "tags": ["test", "export"]
1399        }))
1400        .await
1401        .unwrap();
1402
1403        let r = tool
1404            .execute(json!({"action": "export_markdown", "id": 1}))
1405            .await
1406            .unwrap();
1407        assert!(r.content.contains("# Markdown test"));
1408        assert!(r.content.contains("**Platform:** Medium"));
1409        assert!(r.content.contains("**Status:** Draft"));
1410        assert!(r.content.contains("**Audience:** developers"));
1411        assert!(r.content.contains("**Tone:** casual"));
1412        assert!(r.content.contains("**Tags:** test, export"));
1413        assert!(r.content.contains("Export this content."));
1414    }
1415
1416    #[tokio::test]
1417    async fn test_list_filter_platform() {
1418        let (_dir, tool) = make_tool();
1419
1420        tool.execute(json!({
1421            "action": "create",
1422            "title": "Blog A",
1423            "body": "body",
1424            "platform": "blog"
1425        }))
1426        .await
1427        .unwrap();
1428        tool.execute(json!({
1429            "action": "create",
1430            "title": "Tweet B",
1431            "body": "body",
1432            "platform": "twitter"
1433        }))
1434        .await
1435        .unwrap();
1436
1437        let r = tool
1438            .execute(json!({"action": "list", "platform": "blog"}))
1439            .await
1440            .unwrap();
1441        assert!(r.content.contains("Blog A"));
1442        assert!(!r.content.contains("Tweet B"));
1443    }
1444
1445    #[tokio::test]
1446    async fn test_list_filter_status() {
1447        let (_dir, tool) = make_tool();
1448
1449        tool.execute(json!({
1450            "action": "create",
1451            "title": "Draft piece",
1452            "body": "has body"
1453        }))
1454        .await
1455        .unwrap();
1456        tool.execute(json!({
1457            "action": "create",
1458            "title": "Idea piece"
1459        }))
1460        .await
1461        .unwrap();
1462
1463        let r = tool
1464            .execute(json!({"action": "list", "status": "idea"}))
1465            .await
1466            .unwrap();
1467        assert!(r.content.contains("Idea piece"));
1468        assert!(!r.content.contains("Draft piece"));
1469
1470        let r = tool
1471            .execute(json!({"action": "list", "status": "draft"}))
1472            .await
1473            .unwrap();
1474        assert!(r.content.contains("Draft piece"));
1475        assert!(!r.content.contains("Idea piece"));
1476    }
1477
1478    #[tokio::test]
1479    async fn test_state_roundtrip() {
1480        let (_dir, tool) = make_tool();
1481
1482        // Create some state
1483        tool.execute(json!({
1484            "action": "create",
1485            "title": "Persisted",
1486            "body": "Body text here.",
1487            "platform": "github",
1488            "tags": ["persist"]
1489        }))
1490        .await
1491        .unwrap();
1492        tool.execute(json!({
1493            "action": "calendar_add",
1494            "date": "2026-06-01",
1495            "platform": "github",
1496            "topic": "Release notes"
1497        }))
1498        .await
1499        .unwrap();
1500
1501        // Load raw state and verify roundtrip
1502        let state = tool.load_state();
1503        assert_eq!(state.pieces.len(), 1);
1504        assert_eq!(state.pieces[0].title, "Persisted");
1505        assert_eq!(state.pieces[0].platform, ContentPlatform::GitHub);
1506        assert_eq!(state.pieces[0].word_count, 3);
1507        assert_eq!(state.calendar.len(), 1);
1508        assert_eq!(state.calendar[0].topic, "Release notes");
1509        assert_eq!(state.next_id, 2);
1510
1511        // Save and reload
1512        tool.save_state(&state).unwrap();
1513        let reloaded = tool.load_state();
1514        assert_eq!(reloaded.pieces.len(), 1);
1515        assert_eq!(reloaded.calendar.len(), 1);
1516        assert_eq!(reloaded.next_id, 2);
1517    }
1518
1519    #[tokio::test]
1520    async fn test_unknown_action() {
1521        let (_dir, tool) = make_tool();
1522        let r = tool.execute(json!({"action": "foobar"})).await.unwrap();
1523        assert!(r.content.contains("Unknown action"));
1524        assert!(r.content.contains("foobar"));
1525    }
1526}