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