1use anyhow::{Context, Result};
2use atty;
3use chrono::{DateTime, Utc};
4use colored::*;
5use handlebars::Handlebars;
6use portable_pty::{native_pty_system, CommandBuilder, PtySize};
7use serde::{Deserialize, Serialize};
8use shell_words;
9use std::collections::HashMap;
10use std::fs;
11use std::io::{BufReader, Write};
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::thread;
17use std::time::{Duration, Instant};
18use crate::captain::license;
19#[derive(Debug, Serialize, Deserialize, Clone)]
20pub struct Journey {
21 pub name: String,
22 pub description: String,
23 pub created: DateTime<Utc>,
24 pub commands: Vec<JourneyCommand>,
25 pub variables: HashMap<String, String>,
26 pub checkpoints: Vec<Checkpoint>,
27 pub environment: HashMap<String, String>,
28 pub success_rate: f32,
29 #[serde(default)]
30 pub author: Option<String>,
31 #[serde(default)]
32 pub tags: Vec<String>,
33 #[serde(default)]
34 pub downloads: u32,
35 #[serde(default)]
36 pub rating: f32,
37}
38#[derive(Debug, Serialize, Deserialize, Clone)]
39pub struct JourneyCommand {
40 pub command: String,
41 pub args: Vec<String>,
42 pub working_dir: PathBuf,
43 pub expected_exit_code: i32,
44 pub timeout_seconds: u64,
45 pub capture_output: bool,
46 pub pause_before: bool,
47 pub pause_after: bool,
48 pub description: Option<String>,
49}
50#[derive(Debug, Serialize, Deserialize, Clone)]
51pub struct Checkpoint {
52 pub name: String,
53 pub command_index: usize,
54 pub validation: CheckpointValidation,
55}
56#[derive(Debug, Serialize, Deserialize, Clone)]
57pub enum CheckpointValidation {
58 FileExists(PathBuf),
59 FileContains(PathBuf, String),
60 CommandSucceeds(String),
61 Custom(String),
62}
63pub struct JourneyRecorder {
64 recording: Arc<Mutex<Vec<JourneyCommand>>>,
65 is_recording: Arc<AtomicBool>,
66 start_time: Instant,
67 variables: Arc<Mutex<HashMap<String, String>>>,
68}
69impl JourneyRecorder {
70 pub fn new() -> Self {
71 Self {
72 recording: Arc::new(Mutex::new(Vec::new())),
73 is_recording: Arc::new(AtomicBool::new(false)),
74 start_time: Instant::now(),
75 variables: Arc::new(Mutex::new(HashMap::new())),
76 }
77 }
78 pub fn is_recording(&self) -> bool {
79 self.is_recording.load(Ordering::Relaxed)
80 }
81 pub fn start_recording(&self, name: &str) -> Result<()> {
82 if self.is_recording.load(Ordering::Relaxed) {
83 return Err(anyhow::anyhow!("Already recording a journey"));
84 }
85 self.is_recording.store(true, Ordering::Relaxed);
86 println!("π¬ Recording journey: {}", name.cyan().bold());
87 println!("βΊοΈ Press Ctrl+D to stop recording");
88 let recording = self.recording.clone();
89 let is_recording = self.is_recording.clone();
90 thread::spawn(move || {
91 Self::record_session(recording, is_recording);
92 });
93 Ok(())
94 }
95 fn record_session(
96 recording: Arc<Mutex<Vec<JourneyCommand>>>,
97 is_recording: Arc<AtomicBool>,
98 ) {
99 let is_interactive = atty::is(atty::Stream::Stdin)
100 && atty::is(atty::Stream::Stdout);
101 if !is_interactive {
102 println!("β οΈ Journey recording requires an interactive terminal!");
103 println!("π‘ Please run this command directly in your terminal:");
104 println!(" cm journey record <name>");
105 println!("β
Recording stopped - try again in an interactive terminal");
106 is_recording.store(false, Ordering::Relaxed);
107 return;
108 }
109 println!("βΉοΈ Press Ctrl+D to stop recording");
110 println!("π‘ Or type 'stop'/'exit' and press Enter");
111 println!("π Type commands and press Enter to record them");
112 let mut input = String::new();
113 loop {
114 input.clear();
115 print!("$ ");
116 std::io::stdout().flush().unwrap();
117 match std::io::stdin().read_line(&mut input) {
118 Ok(0) => {
119 println!("β
Recording stopped by Ctrl+D");
120 break;
121 }
122 Ok(bytes_read) => {
123 let trimmed = input.trim().to_lowercase();
124 if trimmed == "stop" || trimmed == "exit" {
125 println!("β
Recording stopped by command: {}", trimmed);
126 break;
127 } else if trimmed.is_empty() {
128 continue;
129 } else {
130 let parts: Vec<String> = shell_words::split(trimmed.as_str())
131 .unwrap_or_else(|_| vec![trimmed.clone()]);
132 if !parts.is_empty() {
133 let cmd = JourneyCommand {
134 command: parts[0].clone(),
135 args: parts[1..].to_vec(),
136 working_dir: std::env::current_dir()
137 .unwrap_or_else(|_| PathBuf::from(".")),
138 expected_exit_code: 0,
139 timeout_seconds: 300,
140 capture_output: true,
141 pause_before: false,
142 pause_after: false,
143 description: None,
144 };
145 let mut rec = recording.lock().unwrap();
146 rec.push(cmd);
147 let command_count = rec.len();
148 println!(
149 "π Recorded: {} (total: {})", trimmed, command_count
150 );
151 }
152 }
153 }
154 Err(e) => {
155 println!("β Recording stopped due to input error: {}", e);
156 break;
157 }
158 }
159 }
160 is_recording.store(false, Ordering::Relaxed);
161 }
162 fn parse_command_from_buffer(buffer: &str) -> Option<JourneyCommand> {
163 let lines: Vec<&str> = buffer.lines().collect();
164 if lines.is_empty() {
165 return None;
166 }
167 let last_line = lines.last()?;
168 if !last_line.contains("$") && !last_line.contains("#") {
169 return None;
170 }
171 let command_start = last_line.rfind('$').or_else(|| last_line.rfind('#'))?;
172 let command_str = &last_line[command_start + 1..].trim();
173 if command_str.is_empty() {
174 return None;
175 }
176 let parts: Vec<String> = shell_words::split(command_str).ok()?;
177 if parts.is_empty() {
178 return None;
179 }
180 Some(JourneyCommand {
181 command: parts[0].clone(),
182 args: parts[1..].to_vec(),
183 working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
184 expected_exit_code: 0,
185 timeout_seconds: 300,
186 capture_output: true,
187 pause_before: false,
188 pause_after: false,
189 description: None,
190 })
191 }
192 pub fn stop_recording(&self, name: &str, description: &str) -> Result<Journey> {
193 self.is_recording.store(false, Ordering::Relaxed);
194 let commands = self.recording.lock().unwrap().clone();
195 let optimized_commands = self.optimize_commands(commands);
196 let journey = Journey {
197 name: name.to_string(),
198 description: description.to_string(),
199 created: Utc::now(),
200 commands: optimized_commands,
201 variables: self.variables.lock().unwrap().clone(),
202 checkpoints: self.detect_checkpoints(&self.recording.lock().unwrap()),
203 environment: self.capture_environment(),
204 success_rate: 100.0,
205 author: std::env::var("USER").ok(),
206 tags: Vec::new(),
207 downloads: 0,
208 rating: 0.0,
209 };
210 self.save_journey(&journey)?;
211 println!("β
Journey '{}' recorded successfully!", name.green().bold());
212 println!("π Saved to ~/.shipwreck/journeys/{}.json", name);
213 Ok(journey)
214 }
215 fn optimize_commands(&self, commands: Vec<JourneyCommand>) -> Vec<JourneyCommand> {
216 let mut optimized = Vec::new();
217 let mut last_cmd: Option<JourneyCommand> = None;
218 for cmd in commands {
219 if let Some(ref last) = last_cmd {
220 if last.command == "cargo" && cmd.command == "cargo" {
221 if last.args.get(0) == Some(&"check".to_string())
222 && cmd.args.get(0) == Some(&"check".to_string())
223 {
224 continue;
225 }
226 }
227 if last.command == "cd" && cmd.command == "cd" {
228 last_cmd = Some(cmd);
229 continue;
230 }
231 }
232 if let Some(last) = last_cmd.take() {
233 optimized.push(last);
234 }
235 last_cmd = Some(cmd);
236 }
237 if let Some(last) = last_cmd {
238 optimized.push(last);
239 }
240 optimized
241 }
242 fn detect_checkpoints(&self, commands: &[JourneyCommand]) -> Vec<Checkpoint> {
243 let mut checkpoints = Vec::new();
244 for (i, cmd) in commands.iter().enumerate() {
245 if cmd.command == "cargo" && cmd.args.get(0) == Some(&"build".to_string()) {
246 checkpoints
247 .push(Checkpoint {
248 name: "Build Complete".to_string(),
249 command_index: i,
250 validation: CheckpointValidation::CommandSucceeds(
251 "cargo check".to_string(),
252 ),
253 });
254 }
255 if cmd.command == "cargo" && cmd.args.get(0) == Some(&"test".to_string()) {
256 checkpoints
257 .push(Checkpoint {
258 name: "Tests Pass".to_string(),
259 command_index: i,
260 validation: CheckpointValidation::CommandSucceeds(
261 "cargo test --quiet".to_string(),
262 ),
263 });
264 }
265 }
266 checkpoints
267 }
268 fn capture_environment(&self) -> HashMap<String, String> {
269 let mut env = HashMap::new();
270 for (key, value) in std::env::vars() {
271 if key.starts_with("CARGO_") || key.starts_with("RUST_") {
272 env.insert(key, value);
273 }
274 }
275 env
276 }
277 fn save_journey(&self, journey: &Journey) -> Result<()> {
278 let journey_dir = dirs::home_dir()
279 .context("Could not find home directory")?
280 .join(".shipwreck")
281 .join("journeys");
282 fs::create_dir_all(&journey_dir)?;
283 let journey_file = journey_dir.join(format!("{}.json", journey.name));
284 let json = serde_json::to_string_pretty(journey)?;
285 fs::write(&journey_file, json)?;
286 let template_dir = journey_dir.join("templates");
287 fs::create_dir_all(&template_dir)?;
288 if journey.success_rate > 95.0 {
289 let template_file = template_dir.join(format!("{}.json", journey.name));
290 fs::copy(&journey_file, template_file)?;
291 }
292 Ok(())
293 }
294}
295pub struct JourneyPlayer {
296 handlebars: Handlebars<'static>,
297 variables: HashMap<String, String>,
298 dry_run: bool,
299 interactive: bool,
300}
301impl JourneyPlayer {
302 pub fn new(dry_run: bool, interactive: bool) -> Self {
303 Self {
304 handlebars: Handlebars::new(),
305 variables: HashMap::new(),
306 dry_run,
307 interactive,
308 }
309 }
310 pub fn load_journey(&self, name: &str) -> Result<Journey> {
311 let journey_file = dirs::home_dir()
312 .context("Could not find home directory")?
313 .join(".shipwreck")
314 .join("journeys")
315 .join(format!("{}.json", name));
316 if !journey_file.exists() {
317 return Err(anyhow::anyhow!("Journey '{}' not found", name));
318 }
319 let content = fs::read_to_string(&journey_file)?;
320 let journey: Journey = serde_json::from_str(&content)?;
321 Ok(journey)
322 }
323 pub fn play(&mut self, journey: &Journey) -> Result<()> {
324 println!("π’ Playing journey: {}", journey.name.cyan().bold());
325 println!("π {}", journey.description);
326 println!();
327 self.collect_variables(&journey.variables)?;
328 for (i, cmd) in journey.commands.iter().enumerate() {
329 if let Some(checkpoint) = journey
330 .checkpoints
331 .iter()
332 .find(|c| c.command_index == i)
333 {
334 println!("π Checkpoint: {}", checkpoint.name.yellow());
335 }
336 if cmd.pause_before && self.interactive {
337 println!("βΈοΈ Press Enter to continue...");
338 let mut input = String::new();
339 std::io::stdin().read_line(&mut input)?;
340 }
341 self.execute_command(cmd)?;
342 if cmd.pause_after && self.interactive {
343 println!("βΈοΈ Press Enter to continue...");
344 let mut input = String::new();
345 std::io::stdin().read_line(&mut input)?;
346 }
347 for checkpoint in &journey.checkpoints {
348 if checkpoint.command_index == i {
349 self.validate_checkpoint(checkpoint)?;
350 }
351 }
352 }
353 println!("β
Journey completed successfully!");
354 Ok(())
355 }
356 fn collect_variables(&mut self, defaults: &HashMap<String, String>) -> Result<()> {
357 for (key, default_value) in defaults {
358 if self.interactive {
359 print!(
360 "π Enter value for {} [{}]: ", key.cyan(), default_value.dimmed()
361 );
362 std::io::stdout().flush()?;
363 let mut input = String::new();
364 std::io::stdin().read_line(&mut input)?;
365 let value = input.trim();
366 if value.is_empty() {
367 self.variables.insert(key.clone(), default_value.clone());
368 } else {
369 self.variables.insert(key.clone(), value.to_string());
370 }
371 } else {
372 self.variables.insert(key.clone(), default_value.clone());
373 }
374 }
375 Ok(())
376 }
377 fn execute_command(&self, cmd: &JourneyCommand) -> Result<()> {
378 let command = self.substitute_variables(&cmd.command)?;
379 if command.is_empty()
380 || command
381 .chars()
382 .any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t')
383 {
384 println!("β οΈ Skipping invalid command: '{}'", command);
385 return Ok(());
386 }
387 let args: Result<Vec<String>> = cmd
388 .args
389 .iter()
390 .filter(|arg| {
391 !arg.is_empty()
392 && !arg
393 .chars()
394 .any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t')
395 })
396 .map(|arg| self.substitute_variables(arg))
397 .collect();
398 let args = args?;
399 if let Some(ref desc) = cmd.description {
400 println!("π {}", desc.dimmed());
401 }
402 println!("$ {} {}", command.green(), args.join(" ").green());
403 if self.dry_run {
404 println!(" [DRY RUN - command not executed]");
405 return Ok(());
406 }
407 if command == "cd" {
408 if args.is_empty() {
409 println!("β οΈ Skipping cd command with no arguments");
410 return Ok(());
411 }
412 let target_dir = &args[0];
413 let expanded_path = if target_dir.contains('~') {
414 let output = Command::new("sh")
415 .arg("-c")
416 .arg(format!("echo {}", target_dir))
417 .output()
418 .map_err(|e| anyhow::anyhow!("Failed to expand path: {}", e))?;
419 String::from_utf8_lossy(&output.stdout).trim().to_string()
420 } else {
421 target_dir.clone()
422 };
423 if std::path::Path::new(&expanded_path).exists() {
424 println!("π Changing working directory to: {}", expanded_path.cyan());
425 return Ok(());
426 } else {
427 println!("β Directory does not exist: {}", expanded_path);
428 return Ok(());
429 }
430 }
431 let needs_shell = command.contains('~')
432 || args.iter().any(|arg| arg.contains('~')) || command.contains('$')
433 || args.iter().any(|arg| arg.contains('$'));
434 let status = if needs_shell {
435 let full_command = if args.is_empty() {
436 command.clone()
437 } else {
438 format!("{} {}", command, args.join(" "))
439 };
440 let mut process = Command::new("sh")
441 .arg("-c")
442 .arg(&full_command)
443 .current_dir(&cmd.working_dir)
444 .stdout(
445 if cmd.capture_output { Stdio::piped() } else { Stdio::inherit() },
446 )
447 .stderr(
448 if cmd.capture_output { Stdio::piped() } else { Stdio::inherit() },
449 )
450 .spawn()?;
451 process.wait()?
452 } else {
453 let mut process = Command::new(&command)
454 .args(&args)
455 .current_dir(&cmd.working_dir)
456 .stdout(
457 if cmd.capture_output { Stdio::piped() } else { Stdio::inherit() },
458 )
459 .stderr(
460 if cmd.capture_output { Stdio::piped() } else { Stdio::inherit() },
461 )
462 .spawn()?;
463 process.wait()?
464 };
465 if !status.success() && cmd.expected_exit_code == 0 {
466 return Err(
467 anyhow::anyhow!(
468 "Command failed with exit code: {}", status.code().unwrap_or(- 1)
469 ),
470 );
471 }
472 Ok(())
473 }
474 fn substitute_variables(&self, template: &str) -> Result<String> {
475 self.handlebars
476 .render_template(template, &self.variables)
477 .context("Failed to substitute variables")
478 }
479 fn validate_checkpoint(&self, checkpoint: &Checkpoint) -> Result<()> {
480 match &checkpoint.validation {
481 CheckpointValidation::FileExists(path) => {
482 if !path.exists() {
483 return Err(
484 anyhow::anyhow!(
485 "Checkpoint failed: file {} does not exist", path.display()
486 ),
487 );
488 }
489 }
490 CheckpointValidation::FileContains(path, content) => {
491 let file_content = fs::read_to_string(path)?;
492 if !file_content.contains(content) {
493 return Err(
494 anyhow::anyhow!(
495 "Checkpoint failed: file {} does not contain '{}'", path
496 .display(), content
497 ),
498 );
499 }
500 }
501 CheckpointValidation::CommandSucceeds(cmd) => {
502 let status = Command::new("sh").arg("-c").arg(cmd).status()?;
503 if !status.success() {
504 return Err(
505 anyhow::anyhow!("Checkpoint failed: command '{}' failed", cmd),
506 );
507 }
508 }
509 CheckpointValidation::Custom(script) => {
510 let status = Command::new("sh").arg("-c").arg(script).status()?;
511 if !status.success() {
512 return Err(
513 anyhow::anyhow!("Checkpoint failed: custom validation failed"),
514 );
515 }
516 }
517 }
518 println!("β
Checkpoint passed: {}", checkpoint.name.green());
519 Ok(())
520 }
521}
522pub fn list_journeys() -> Result<Vec<String>> {
523 let journey_dir = dirs::home_dir()
524 .context("Could not find home directory")?
525 .join(".shipwreck")
526 .join("journeys");
527 if !journey_dir.exists() {
528 return Ok(Vec::new());
529 }
530 let mut journeys = Vec::new();
531 for entry in fs::read_dir(&journey_dir)? {
532 let entry = entry?;
533 let path = entry.path();
534 if path.extension() == Some(std::ffi::OsStr::new("json")) {
535 if let Some(stem) = path.file_stem() {
536 journeys.push(stem.to_string_lossy().to_string());
537 }
538 }
539 }
540 Ok(journeys)
541}
542pub fn export_journey(name: &str, output: &Path) -> Result<()> {
543 let journey_file = dirs::home_dir()
544 .context("Could not find home directory")?
545 .join(".shipwreck")
546 .join("journeys")
547 .join(format!("{}.json", name));
548 if !journey_file.exists() {
549 return Err(anyhow::anyhow!("Journey '{}' not found", name));
550 }
551 fs::copy(&journey_file, output)?;
552 println!("β
Journey exported to {}", output.display());
553 Ok(())
554}
555pub fn import_journey(path: &Path) -> Result<()> {
556 let content = fs::read_to_string(path)?;
557 let journey: Journey = serde_json::from_str(&content)?;
558 let journey_dir = dirs::home_dir()
559 .context("Could not find home directory")?
560 .join(".shipwreck")
561 .join("journeys");
562 fs::create_dir_all(&journey_dir)?;
563 let journey_file = journey_dir.join(format!("{}.json", journey.name));
564 fs::write(&journey_file, content)?;
565 println!("β
Journey '{}' imported successfully!", journey.name.green());
566 Ok(())
567}
568#[derive(Debug, Serialize, Deserialize)]
569pub struct MarketplaceJourney {
570 pub gist_id: String,
571 pub name: String,
572 pub description: String,
573 pub author: String,
574 pub tags: Vec<String>,
575 pub downloads: u32,
576 pub rating: f32,
577 pub created: DateTime<Utc>,
578 pub updated: DateTime<Utc>,
579}
580pub struct JourneyMarketplace;
581impl JourneyMarketplace {
582 pub fn publish(name: &str, tags: Vec<String>) -> Result<String> {
583 if std::env::var("CARGO_MATE_MARKETPLACE").is_ok() {
584 let email = std::env::var("CARGO_MATE_EMAIL")
585 .unwrap_or_else(|_| "user@cargo.do".to_string());
586 let category = std::env::var("CARGO_MATE_CATEGORY")
587 .unwrap_or_else(|_| "getting-started".to_string());
588 return Self::publish_to_marketplace(name, &email, tags, &category);
589 }
590 Self::publish_to_gist(name, tags)
591 }
592 pub fn publish_to_gist(name: &str, tags: Vec<String>) -> Result<String> {
593 let journey_file = dirs::home_dir()
594 .context("Could not find home directory")?
595 .join(".shipwreck")
596 .join("journeys")
597 .join(format!("{}.json", name));
598 if !journey_file.exists() {
599 return Err(anyhow::anyhow!("Journey '{}' not found", name));
600 }
601 let content = fs::read_to_string(&journey_file)?;
602 let mut journey: Journey = serde_json::from_str(&content)?;
603 journey.tags = tags;
604 let json = serde_json::to_string_pretty(&journey)?;
605 println!("π€ Publishing journey '{}' to GitHub Gist...", name.cyan());
606 let gist_json = serde_json::json!(
607 { "description" : format!("Cargo Mate Journey: {} - {}", journey.name,
608 journey.description), "public" : true, "files" : { format!("{}.json", name) :
609 { "content" : json } } }
610 );
611 let temp_file = std::env::temp_dir()
612 .join(format!("cargo-mate-gist-{}.json", name));
613 fs::write(&temp_file, gist_json.to_string())?;
614 let output = Command::new("gh")
615 .args(&["api", "gists"])
616 .arg("--method")
617 .arg("POST")
618 .arg("--input")
619 .arg(&temp_file)
620 .stdout(Stdio::piped())
621 .stderr(Stdio::piped())
622 .output()?;
623 let _ = fs::remove_file(&temp_file);
624 if !output.status.success() {
625 let error = String::from_utf8_lossy(&output.stderr);
626 return Err(anyhow::anyhow!("GitHub CLI error: {}", error));
627 }
628 let result = String::from_utf8_lossy(&output.stdout);
629 if result.trim().is_empty() {
630 return Err(anyhow::anyhow!("GitHub CLI returned empty response"));
631 }
632 let gist_response: serde_json::Value = serde_json::from_str(&result)
633 .map_err(|e| anyhow::anyhow!("Failed to parse GitHub response: {}", e))?;
634 let gist_id = gist_response["id"]
635 .as_str()
636 .ok_or_else(|| {
637 anyhow::anyhow!("Failed to get gist ID from response: {}", result)
638 })?;
639 let html_url = gist_response["html_url"]
640 .as_str()
641 .ok_or_else(|| {
642 anyhow::anyhow!("Failed to get gist URL from response: {}", result)
643 })?;
644 println!("β
Journey published successfully!");
645 println!("π Gist URL: {}", html_url.cyan());
646 println!("π Share ID: {}", gist_id.green());
647 Self::save_published_record(name, gist_id)?;
648 Ok(gist_id.to_string())
649 }
650 pub fn download(gist_id: &str) -> Result<()> {
651 println!("π₯ Downloading journey from gist {}...", gist_id.cyan());
652 let output = Command::new("gh")
653 .args(&["api", &format!("gists/{}", gist_id)])
654 .stdout(Stdio::piped())
655 .stderr(Stdio::piped())
656 .output()?;
657 if !output.status.success() {
658 let error = String::from_utf8_lossy(&output.stderr);
659 return Err(anyhow::anyhow!("Failed to download gist: {}", error));
660 }
661 let gist_response: serde_json::Value = serde_json::from_slice(&output.stdout)?;
662 let files = gist_response["files"]
663 .as_object()
664 .ok_or_else(|| anyhow::anyhow!("No files found in gist"))?;
665 for (filename, file_data) in files {
666 if filename.ends_with(".json") {
667 let content = file_data["content"]
668 .as_str()
669 .ok_or_else(|| anyhow::anyhow!("Failed to get file content"))?;
670 let journey: Journey = serde_json::from_str(content)?;
671 let journey_dir = dirs::home_dir()
672 .context("Could not find home directory")?
673 .join(".shipwreck")
674 .join("journeys");
675 fs::create_dir_all(&journey_dir)?;
676 let journey_file = journey_dir.join(format!("{}.json", journey.name));
677 fs::write(&journey_file, content)?;
678 println!(
679 "β
Journey '{}' downloaded successfully!", journey.name.green()
680 );
681 println!("π Description: {}", journey.description);
682 if let Some(author) = &journey.author {
683 println!("π€ Author: {}", author.cyan());
684 }
685 if !journey.tags.is_empty() {
686 println!("π·οΈ Tags: {}", journey.tags.join(", "));
687 }
688 return Ok(());
689 }
690 }
691 Err(anyhow::anyhow!("No valid journey file found in gist"))
692 }
693 pub fn search(query: &str) -> Result<Vec<MarketplaceJourney>> {
694 println!("π Searching for journeys matching '{}'...", query.cyan());
695 let output = Command::new("gh")
696 .args(&["api", "search/gists"])
697 .arg("-X")
698 .arg("GET")
699 .arg("-f")
700 .arg(format!("q=Cargo Mate Journey {}", query))
701 .stdout(Stdio::piped())
702 .stderr(Stdio::piped())
703 .output()?;
704 if !output.status.success() {
705 let error = String::from_utf8_lossy(&output.stderr);
706 return Err(anyhow::anyhow!("Search failed: {}", error));
707 }
708 let search_response: serde_json::Value = serde_json::from_slice(&output.stdout)?;
709 let items = search_response["items"]
710 .as_array()
711 .ok_or_else(|| anyhow::anyhow!("No search results found"))?;
712 let mut journeys = Vec::new();
713 for item in items {
714 if let (Some(id), Some(description)) = (
715 item["id"].as_str(),
716 item["description"].as_str(),
717 ) {
718 if description.starts_with("Cargo Mate Journey:") {
719 let parts: Vec<&str> = description.splitn(3, " - ").collect();
720 if parts.len() >= 2 {
721 let name = parts[0].replace("Cargo Mate Journey: ", "");
722 let desc = parts.get(1).unwrap_or(&"").to_string();
723 journeys
724 .push(MarketplaceJourney {
725 gist_id: id.to_string(),
726 name,
727 description: desc,
728 author: item["owner"]["login"]
729 .as_str()
730 .unwrap_or("unknown")
731 .to_string(),
732 tags: Vec::new(),
733 downloads: 0,
734 rating: 0.0,
735 created: DateTime::parse_from_rfc3339(
736 item["created_at"].as_str().unwrap_or(""),
737 )
738 .ok()
739 .map(|dt| dt.with_timezone(&Utc))
740 .unwrap_or_else(Utc::now),
741 updated: DateTime::parse_from_rfc3339(
742 item["updated_at"].as_str().unwrap_or(""),
743 )
744 .ok()
745 .map(|dt| dt.with_timezone(&Utc))
746 .unwrap_or_else(Utc::now),
747 });
748 }
749 }
750 }
751 }
752 if journeys.is_empty() {
753 println!("No journeys found matching your search.");
754 } else {
755 println!("Found {} journey(s):", journeys.len());
756 for (i, journey) in journeys.iter().enumerate() {
757 println!(
758 "\n{}. {} by {}", i + 1, journey.name.cyan(), journey.author.green()
759 );
760 println!(" {}", journey.description);
761 println!(" ID: {}", journey.gist_id.dimmed());
762 }
763 }
764 Ok(journeys)
765 }
766 pub fn list_published() -> Result<Vec<String>> {
767 let published_file = dirs::home_dir()
768 .context("Could not find home directory")?
769 .join(".shipwreck")
770 .join("journeys")
771 .join(".published.json");
772 if !published_file.exists() {
773 return Ok(Vec::new());
774 }
775 let content = fs::read_to_string(&published_file)?;
776 let published: HashMap<String, String> = serde_json::from_str(&content)?;
777 let mut journeys = Vec::new();
778 for (name, gist_id) in published {
779 journeys.push(format!("{} ({})", name, gist_id));
780 }
781 Ok(journeys)
782 }
783 fn save_published_record(name: &str, gist_id: &str) -> Result<()> {
784 let published_file = dirs::home_dir()
785 .context("Could not find home directory")?
786 .join(".shipwreck")
787 .join("journeys")
788 .join(".published.json");
789 let mut published: HashMap<String, String> = if published_file.exists() {
790 let content = fs::read_to_string(&published_file)?;
791 serde_json::from_str(&content)?
792 } else {
793 HashMap::new()
794 };
795 published.insert(name.to_string(), gist_id.to_string());
796 let json = serde_json::to_string_pretty(&published)?;
797 fs::write(&published_file, json)?;
798 Ok(())
799 }
800 pub fn publish_to_marketplace(
801 name: &str,
802 email: &str,
803 tags: Vec<String>,
804 category: &str,
805 ) -> Result<String> {
806 let journey_file = dirs::home_dir()
807 .context("Could not find home directory")?
808 .join(".shipwreck")
809 .join("journeys")
810 .join(format!("{}.json", name));
811 if !journey_file.exists() {
812 return Err(anyhow::anyhow!("Journey '{}' not found locally", name));
813 }
814 let content = fs::read_to_string(&journey_file)?;
815 let mut journey: Journey = serde_json::from_str(&content)?;
816 journey.tags = tags;
817 journey.tags.push(category.to_string());
818 let json = serde_json::to_string_pretty(&journey)?;
819 println!(
820 "π€ Publishing journey '{}' to Cargo Mate Marketplace...", name.cyan()
821 );
822 println!(" π§ Using email: {}", email.cyan());
823 println!(" π·οΈ Category: {}", category.cyan());
824 println!(" π Tags: {}", journey.tags.join(", ").cyan());
825 let journey_data = serde_json::json!(
826 { "email" : email, "journey_data" : { "journey_id" : journey.name
827 .to_lowercase().replace(" ", "_"), "title" : journey.name, "description" :
828 journey.description, "category" : category, "tags" : journey.tags,
829 "difficulty" : "intermediate", "estimated_duration" : 30, "target_audience" :
830 "Rust developers", "prerequisites" : "Basic Rust knowledge" }, "files" : [{
831 "filename" : format!("{}.json", journey.name), "type" : "json", "content" :
832 json }] }
833 );
834 let temp_file = std::env::temp_dir()
835 .join(format!("cargo-mate-marketplace-{}.json", name));
836 fs::write(&temp_file, journey_data.to_string())?;
837 let output = Command::new("curl")
838 .args(
839 &[
840 "-X",
841 "POST",
842 "-H",
843 "Content-Type: application/json",
844 "-d",
845 &journey_data.to_string(),
846 "https://cargo.do/api/marketplace/publish-journey",
847 ],
848 )
849 .stdout(Stdio::piped())
850 .stderr(Stdio::piped())
851 .output()?;
852 let _ = fs::remove_file(&temp_file);
853 if !output.status.success() {
854 let error = String::from_utf8_lossy(&output.stderr);
855 return Err(anyhow::anyhow!("Marketplace API error: {}", error));
856 }
857 let response = String::from_utf8_lossy(&output.stdout);
858 if response.trim().is_empty() {
859 return Err(anyhow::anyhow!("Marketplace API returned empty response"));
860 }
861 let api_response: serde_json::Value = serde_json::from_str(&response)
862 .map_err(|e| {
863 anyhow::anyhow!("Failed to parse marketplace response: {}", e)
864 })?;
865 if api_response["success"].as_bool().unwrap_or(false) {
866 let journey_id = api_response["journey_id"]
867 .as_u64()
868 .ok_or_else(|| {
869 anyhow::anyhow!("Failed to get journey ID from response")
870 })?;
871 println!("β
Journey published successfully!");
872 println!("π Marketplace ID: {}", journey_id.to_string().green());
873 println!("π View at: https://cargo.do/marketplace");
874 Ok(journey_id.to_string())
875 } else {
876 let error = api_response["error"].as_str().unwrap_or("Unknown error");
877 Err(anyhow::anyhow!("Marketplace error: {}", error))
878 }
879 }
880 pub fn download_from_marketplace(
881 journey_id: &str,
882 email: Option<&str>,
883 ) -> Result<()> {
884 println!(
885 "π₯ Downloading journey {} from Cargo Mate Marketplace...", journey_id
886 .cyan()
887 );
888 let download_data = serde_json::json!(
889 { "journey_id" : journey_id.parse::< u64 > ().unwrap_or(0), "email" : email
890 .unwrap_or("") }
891 );
892 let output = Command::new("curl")
893 .args(
894 &[
895 "-X",
896 "POST",
897 "-H",
898 "Content-Type: application/json",
899 "-d",
900 &download_data.to_string(),
901 "https://cargo.do/api/marketplace/download-journey",
902 ],
903 )
904 .stdout(Stdio::piped())
905 .stderr(Stdio::piped())
906 .output()?;
907 if !output.status.success() {
908 let error = String::from_utf8_lossy(&output.stderr);
909 return Err(anyhow::anyhow!("Marketplace download error: {}", error));
910 }
911 let response = String::from_utf8_lossy(&output.stdout);
912 if response.trim().is_empty() {
913 return Err(anyhow::anyhow!("Marketplace download returned empty response"));
914 }
915 let api_response: serde_json::Value = serde_json::from_str(&response)
916 .map_err(|e| anyhow::anyhow!("Failed to parse download response: {}", e))?;
917 if api_response["success"].as_bool().unwrap_or(false) {
918 if let Some(files) = api_response["files"].as_array() {
919 if let Some(file) = files.get(0) {
920 if let (Some(filename), Some(content)) = (
921 file["filename"].as_str(),
922 file["content"].as_str(),
923 ) {
924 let journey_dir = dirs::home_dir()
925 .context("Could not find home directory")?
926 .join(".shipwreck")
927 .join("journeys");
928 fs::create_dir_all(&journey_dir)?;
929 let journey_file = journey_dir.join(filename);
930 fs::write(&journey_file, content)?;
931 println!("β
Journey downloaded successfully!");
932 println!(
933 "π Saved to: {}", journey_file.display().to_string()
934 .dimmed()
935 );
936 if let Ok(journey) = serde_json::from_str::<Journey>(content) {
937 println!("π Title: {}", journey.name.cyan());
938 println!("π Description: {}", journey.description);
939 if let Some(author) = &journey.author {
940 println!("π€ Author: {}", author.green());
941 }
942 if !journey.tags.is_empty() {
943 println!("π·οΈ Tags: {}", journey.tags.join(", "));
944 }
945 }
946 return Ok(());
947 }
948 }
949 }
950 return Err(anyhow::anyhow!("No valid files found in download response"));
951 } else {
952 let error = api_response["error"].as_str().unwrap_or("Unknown error");
953 Err(anyhow::anyhow!("Marketplace download error: {}", error))
954 }
955 }
956 pub fn search_marketplace(
957 query: &str,
958 category: Option<&str>,
959 limit: usize,
960 ) -> Result<Vec<MarketplaceJourney>> {
961 println!("π Searching Cargo Mate Marketplace for '{}'...", query.cyan());
962 let params = if let Some(cat) = category {
963 format!(
964 "q={}&category={}&limit={}", urlencoding::encode(query),
965 urlencoding::encode(cat), limit
966 )
967 } else {
968 format!("q={}&limit={}", urlencoding::encode(query), limit)
969 };
970 let output = Command::new("curl")
971 .args(
972 &[
973 "-X",
974 "GET",
975 &format!(
976 "https://cargo.do/api/marketplace/search-journeys?{}", params
977 ),
978 ],
979 )
980 .stdout(Stdio::piped())
981 .stderr(Stdio::piped())
982 .output()?;
983 if !output.status.success() {
984 let error = String::from_utf8_lossy(&output.stderr);
985 return Err(anyhow::anyhow!("Marketplace search error: {}", error));
986 }
987 let response = String::from_utf8_lossy(&output.stdout);
988 if response.trim().is_empty() {
989 return Err(anyhow::anyhow!("Marketplace search returned empty response"));
990 }
991 let api_response: serde_json::Value = serde_json::from_str(&response)
992 .map_err(|e| anyhow::anyhow!("Failed to parse search response: {}", e))?;
993 if api_response["success"].as_bool().unwrap_or(false) {
994 let mut journeys = Vec::new();
995 if let Some(journeys_array) = api_response["journeys"].as_array() {
996 for journey in journeys_array {
997 journeys
998 .push(MarketplaceJourney {
999 gist_id: journey["journey_id"]
1000 .as_str()
1001 .unwrap_or("unknown")
1002 .to_string(),
1003 name: journey["title"]
1004 .as_str()
1005 .unwrap_or("Unknown")
1006 .to_string(),
1007 description: journey["description"]
1008 .as_str()
1009 .unwrap_or("")
1010 .to_string(),
1011 author: journey["author"]["username"]
1012 .as_str()
1013 .unwrap_or("unknown")
1014 .to_string(),
1015 tags: journey["tags"]
1016 .as_array()
1017 .map(|tags| {
1018 tags.iter()
1019 .filter_map(|t| t.as_str())
1020 .map(|s| s.to_string())
1021 .collect()
1022 })
1023 .unwrap_or_default(),
1024 downloads: journey["download_count"].as_u64().unwrap_or(0)
1025 as u32,
1026 rating: journey["average_rating"].as_f64().unwrap_or(0.0)
1027 as f32,
1028 created: DateTime::parse_from_rfc3339(
1029 journey["created_at"].as_str().unwrap_or(""),
1030 )
1031 .ok()
1032 .map(|dt| dt.with_timezone(&Utc))
1033 .unwrap_or_else(Utc::now),
1034 updated: DateTime::parse_from_rfc3339(
1035 journey["updated_at"].as_str().unwrap_or(""),
1036 )
1037 .ok()
1038 .map(|dt| dt.with_timezone(&Utc))
1039 .unwrap_or_else(Utc::now),
1040 });
1041 }
1042 }
1043 if journeys.is_empty() {
1044 println!("No journeys found matching your search.");
1045 } else {
1046 println!("Found {} journey(s) in marketplace:", journeys.len());
1047 for (i, journey) in journeys.iter().enumerate() {
1048 println!(
1049 "\n{}. {} by {}", i + 1, journey.name.cyan(), journey.author
1050 .green()
1051 );
1052 println!(" {}", journey.description);
1053 println!(
1054 " β Rating: {:.1} β’ π₯ Downloads: {} β’ π·οΈ Tags: {}",
1055 journey.rating, journey.downloads, journey.tags.join(", ")
1056 );
1057 println!(" π ID: {}", journey.gist_id.dimmed());
1058 }
1059 }
1060 Ok(journeys)
1061 } else {
1062 let error = api_response["error"].as_str().unwrap_or("Unknown error");
1063 Err(anyhow::anyhow!("Marketplace search error: {}", error))
1064 }
1065 }
1066}
1067pub fn check_buoy_clearance(command: &str) -> Result<bool> {
1068 println!(
1069 "π Buoy check! Verifying command '{}' through the navigation channel", command
1070 .cyan()
1071 );
1072 let license_manager = license::LicenseManager::new();
1073 match license_manager?.enforce_license(command) {
1074 Ok(_) => {
1075 println!(
1076 "β
Clear sailing! Command '{}' passed all navigation buoys!", command
1077 .green()
1078 );
1079 println!(" π All channel markers are green - proceed!");
1080 Ok(true)
1081 }
1082 Err(e) => {
1083 if e.to_string().contains("limit") {
1084 println!("β οΈ Red buoy alert! Usage quota exceeded!");
1085 println!(" π§ Safe harbor: https://cargo.do/checkout");
1086 println!(" π Drop anchor and upgrade to continue");
1087 } else if e.to_string().contains("License not found") {
1088 println!("β No navigation beacon detected!");
1089 println!(" π‘ Register clearance with 'cm register <key>'");
1090 } else {
1091 println!(
1092 "β Stormy waters! Navigation check failed: {}", e.to_string().red()
1093 );
1094 println!(" π Seek safe harbor and contact support");
1095 }
1096 Ok(false)
1097 }
1098 }
1099}