1use 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, 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 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 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 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 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 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 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 let get1 = tool
1032 .execute(json!({"action": "get", "id": 1}))
1033 .await
1034 .unwrap();
1035 assert!(get1.content.contains("Words: 3"));
1036
1037 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 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 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 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 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 let r = tool
1121 .execute(json!({"action": "search", "query": "pods"}))
1122 .await
1123 .unwrap();
1124 assert!(r.content.contains("Kubernetes"));
1125
1126 let r = tool
1128 .execute(json!({"action": "search", "query": "devops"}))
1129 .await
1130 .unwrap();
1131 assert!(r.content.contains("Kubernetes"));
1132
1133 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 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 let cal = tool
1165 .execute(json!({"action": "calendar_list"}))
1166 .await
1167 .unwrap();
1168 assert!(cal.content.contains("Publish blog post"));
1169
1170 let del = tool
1172 .execute(json!({"action": "delete", "id": 1}))
1173 .await
1174 .unwrap();
1175 assert!(del.content.contains("Deleted content #1"));
1176
1177 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 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 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 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 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 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 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 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")); 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 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 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 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}