Skip to main content

jump_start/
starter.rs

1use crate::LocalStarterGroupLookup;
2use glob::glob;
3use log::debug;
4use log::error;
5use serde::{Deserialize, Serialize};
6use serde_yaml;
7use std::collections::HashMap;
8use std::fs;
9use std::io;
10use std::path::Path;
11use std::path::PathBuf;
12use std::str::FromStr;
13
14#[derive(Debug, Serialize, Deserialize, Clone)]
15pub struct PreviewConfig {
16    pub template: Option<String>,
17    pub dependencies: Option<HashMap<String, String>>,
18}
19
20#[derive(Debug, Serialize, Deserialize, Clone)]
21pub struct StarterConfig {
22    pub description: Option<String>,
23    #[serde(rename = "defaultDir")]
24    pub default_dir: Option<PathBuf>,
25    #[serde(rename = "mainFile")]
26    pub main_file: Option<String>,
27    pub preview: Option<PreviewConfig>,
28}
29
30impl FromStr for StarterConfig {
31    type Err = serde_yaml::Error;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        serde_yaml::from_str(s)
35    }
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone)]
39pub struct LocalStarterFile {
40    pub path: String,
41    pub contents: String,
42}
43
44/// A string idenfitying a starter. Takes the form "[INSTANCE]/GROUP/NAME", where INSTANCE, if
45/// unspecified, defaults to the default instance.
46// pub type StarterIdentifier = String;
47#[derive(Debug, Serialize, Deserialize, Clone)]
48pub struct RemoteStarter {
49    pub github_username: String,
50    pub github_repo: String,
51    pub group: String,
52    pub name: String,
53}
54
55impl RemoteStarter {
56    pub fn new(github_username: &str, github_repo: &str, group: &str, name: &str) -> Self {
57        Self {
58            github_username: github_username.to_string(),
59            github_repo: github_repo.to_string(),
60            group: group.to_string(),
61            name: name.to_string(),
62        }
63    }
64
65    /// A string idenfitying a starter. Takes the following form:
66    /// @GITHUB_USERNAME/[GITHUB_REPO]/GROUP/NAME
67    ///
68    /// # Examples
69    ///
70    /// When left unspecified, `GITHUB_REPO` defaults to "jump-start"
71    ///
72    /// ```
73    /// use jump_start::RemoteStarter;
74    /// let starter = RemoteStarter::from_path("@kevinschaul/react-d3/Chart").unwrap();
75    /// assert_eq!(starter.github_username, "kevinschaul");
76    /// assert_eq!(starter.github_repo, "jump-start");
77    /// assert_eq!(starter.group, "react-d3");
78    /// assert_eq!(starter.name, "Chart");
79    /// ```
80    ///
81    /// ```
82    /// use jump_start::RemoteStarter;
83    /// let starter = RemoteStarter::from_path("@kevinschaul/starters/react-d3/Chart").unwrap();
84    /// assert_eq!(starter.github_username, "kevinschaul");
85    /// assert_eq!(starter.github_repo, "starters");
86    /// assert_eq!(starter.group, "react-d3");
87    /// assert_eq!(starter.name, "Chart");
88    /// ```
89    ///
90    /// ```should_panic
91    /// use jump_start::RemoteStarter;
92    /// let starter = RemoteStarter::from_path("react-d3/Chart").unwrap();
93    /// ```
94    ///
95    /// ```should_panic
96    /// use jump_start::RemoteStarter;
97    /// let starter = RemoteStarter::from_path("@kevinschaul/Chart").unwrap();
98    /// ```
99    pub fn from_path(path: &str) -> Option<Self> {
100        let parts: Vec<&str> = path.split('/').collect();
101        // Trim off the leading '@' character
102        let github_username = &parts[0][1..];
103
104        match parts.len() {
105            3 => {
106                let github_repo = "jump-start";
107                Some(Self::new(github_username, github_repo, parts[1], parts[2]))
108            }
109            4 => {
110                let github_repo = parts[1];
111                Some(Self::new(github_username, github_repo, parts[2], parts[3]))
112            }
113            _ => panic!("Could not parse remote starter from string {:?}", path),
114        }
115    }
116}
117
118#[derive(Debug, Serialize, Deserialize, Clone)]
119pub struct LocalStarter {
120    /// Full path identifier (group/name)
121    pub path: String,
122    /// Group or category this starter belongs to
123    pub group: String,
124    /// Name of this starter within its group
125    pub name: String,
126    /// Configuration stored in the starter's jump-start.yaml file
127    pub config: Option<StarterConfig>,
128}
129
130impl LocalStarter {
131    pub fn new(group: &str, name: &str) -> Self {
132        let path = format!("{}/{}", group, name);
133
134        Self {
135            group: group.to_string(),
136            name: name.to_string(),
137            path,
138            config: None,
139        }
140    }
141
142    /// Parse a path (group/name) into a Starter
143    pub fn from_path(path: &str) -> Option<Self> {
144        let parts: Vec<&str> = path.split('/').collect();
145        if parts.len() != 2 {
146            return None;
147        }
148
149        Some(Self::new(parts[0], parts[1]))
150    }
151}
152
153pub fn parse_starters(path: &Path) -> io::Result<LocalStarterGroupLookup> {
154    let mut groups: LocalStarterGroupLookup = HashMap::new();
155
156    // Define the glob pattern
157    let pattern = format!("{}/**/*jump-start.yaml", path.display());
158
159    // Use glob to find all matching files
160    for entry in glob(&pattern).expect("Failed to read glob pattern") {
161        match entry {
162            Ok(path) => {
163                // Skip files in node_modules and jump-start-tools
164                let path_str = path.to_string_lossy();
165                if path_str.contains("node_modules") || path_str.contains("jump-start-tools") {
166                    continue;
167                }
168
169                // Read and parse the YAML file
170                let file_content = fs::read_to_string(&path)?;
171                debug!("Parsing YAML file: {}", path.display());
172                debug!("Content: {}", file_content);
173
174                let starter_config = match file_content.parse::<StarterConfig>() {
175                    Ok(config) => config,
176                    Err(e) => {
177                        error!("Error parsing yaml for {}: {}", path.display(), e);
178                        continue;
179                    }
180                };
181
182                let current_dir = path.parent().unwrap();
183                let name = current_dir
184                    .file_name()
185                    .unwrap()
186                    .to_string_lossy()
187                    .to_string();
188                let group = current_dir
189                    .parent()
190                    .unwrap()
191                    .file_name()
192                    .unwrap()
193                    .to_string_lossy()
194                    .to_string();
195
196                let starter = LocalStarter {
197                    name: name.clone(),
198                    group: group.clone(),
199                    path: format!("{}/{}", group, name),
200                    config: Some(starter_config),
201                };
202
203                groups.entry(group).or_default().push(starter);
204            }
205            Err(e) => error!("Error processing glob entry: {}", e),
206        }
207    }
208
209    Ok(groups)
210}
211
212// Get files for a starter using the instance directory as the base path
213pub fn get_starter_files(
214    starter: &LocalStarter,
215    instance_dir: &Path,
216) -> io::Result<Vec<LocalStarterFile>> {
217    let mut out = Vec::new();
218    let excluded_files = ["jump-start.yaml", "degit.json"];
219
220    // Get the full path to the starter directory using the instance dir as base
221    let starter_dir = instance_dir.join(&starter.group).join(&starter.name);
222
223    if starter_dir.exists() && starter_dir.is_dir() {
224        // Walk the directory recursively
225        visit_dirs(
226            &starter_dir,
227            &mut out,
228            &excluded_files,
229            &starter_dir.to_string_lossy(),
230        )?;
231        debug!(
232            "Found {} files for starter {}/{}",
233            out.len(),
234            starter.group,
235            starter.name
236        );
237    } else {
238        error!("Warning: Starter directory not found: {:?}", starter_dir);
239        // TODO why would I do this?
240        // Add a sample file if no files are found, so that the UI works
241        out.push(LocalStarterFile {
242            path: "example.file".to_string(),
243            contents: "// This is a sample file content\nconsole.log('Hello world');\n".to_string(),
244        });
245    }
246
247    Ok(out)
248}
249
250fn visit_dirs(
251    dir: &Path,
252    files: &mut Vec<LocalStarterFile>,
253    excluded_files: &[&str],
254    base_path: &str,
255) -> io::Result<()> {
256    if dir.is_dir() {
257        for entry in fs::read_dir(dir)? {
258            let entry = entry?;
259            let path = entry.path();
260
261            if path.is_dir() {
262                visit_dirs(&path, files, excluded_files, base_path)?;
263            } else if let Some(file_name) = path.file_name() {
264                let file_name_str = file_name.to_string_lossy();
265
266                if !excluded_files.contains(&file_name_str.as_ref()) {
267                    // Get relative path
268                    let base = Path::new(base_path);
269                    let rel_path = match path.strip_prefix(base) {
270                        Ok(rel) => rel.to_string_lossy().to_string(),
271                        Err(_) => path.to_string_lossy().to_string(),
272                    };
273
274                    // Try to read file contents
275                    match fs::read_to_string(&path) {
276                        Ok(contents) => {
277                            // Create and push StarterFile
278                            files.push(LocalStarterFile {
279                                path: rel_path,
280                                contents,
281                            });
282                        }
283                        Err(e) => {
284                            error!("Warning: Could not read file {:?}: {}", path, e);
285                            // Try to handle binary files by reading as bytes
286                            // but for now just skip them
287                        }
288                    }
289                }
290            }
291        }
292    }
293
294    Ok(())
295}
296
297pub fn get_starter_command(
298    starter: &LocalStarter,
299    github_username: &str,
300    github_repo: &str,
301) -> String {
302    if !github_username.is_empty() && !github_repo.is_empty() {
303        if github_repo == "jump-start" {
304            format!("jump-start use @{}/{}/{}", github_username, starter.group, starter.name)
305        } else {
306            format!("jump-start use @{}/{}/{}/{}", github_username, github_repo, starter.group, starter.name)
307        }
308    } else {
309        format!("jump-start use {}/{}", starter.group, starter.name)
310    }
311}