Skip to main content

cargo_mate/
journey.rs

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}