Skip to main content

lux_cli/project/
new.rs

1use std::{error::Error, fmt::Display, path::PathBuf, str::FromStr};
2
3use clap::Args;
4use inquire::{
5    ui::{RenderConfig, Styled},
6    validator::Validation,
7    Confirm, Select, Text,
8};
9use itertools::Itertools;
10use miette::{miette, IntoDiagnostic, Result};
11use spdx::LicenseId;
12use spinners::{Spinner, Spinners};
13
14use crate::utils::github_metadata::{self, RepoMetadata};
15use lux_lib::{
16    config::Config,
17    package::PackageReq,
18    project::{Project, PROJECT_TOML},
19};
20
21// TODO:
22// - Automatically detect build type to insert into rockspec by inspecting the current repo.
23//   E.g. if there is a `Cargo.toml` in the project root we can infer the user wants to use the
24//   Rust build backend.
25
26/// The type of directory to create when making the project.
27#[derive(Debug, Clone, clap::ValueEnum)]
28enum SourceDirType {
29    Src,
30    Lua,
31}
32
33impl Display for SourceDirType {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Src => write!(f, "src"),
37            Self::Lua => write!(f, "lua"),
38        }
39    }
40}
41
42#[derive(Args)]
43pub struct NewProject {
44    /// The directory of the project.
45    target: PathBuf,
46
47    /// The project's name.
48    #[arg(long)]
49    name: Option<String>,
50
51    /// The description of the project.
52    #[arg(long)]
53    description: Option<String>,
54
55    /// The license of the project. Generic license names will be inferred.
56    #[arg(long, value_parser = clap_parse_license)]
57    license: Option<LicenseId>,
58
59    /// The maintainer of this project. Does not have to be the code author.
60    #[arg(long)]
61    maintainer: Option<String>,
62
63    /// A comma-separated list of labels to apply to this project.
64    #[arg(long, value_parser = clap_parse_list)]
65    labels: Option<std::vec::Vec<String>>, // Note: full qualified name required, see https://github.com/clap-rs/clap/issues/4626
66
67    /// A version constraint on the required Lua version for this project.
68    /// Examples: ">=5.1", "5.1"
69    #[arg(long, value_parser = clap_parse_version)]
70    lua_versions: Option<PackageReq>,
71
72    #[arg(long)]
73    main: Option<SourceDirType>,
74}
75
76struct NewProjectValidated {
77    target: PathBuf,
78    name: String,
79    description: String,
80    maintainer: String,
81    labels: Vec<String>,
82    lua_versions: PackageReq,
83    main: SourceDirType,
84    license: Option<LicenseId>,
85}
86
87fn clap_parse_license(s: &str) -> std::result::Result<LicenseId, String> {
88    match validate_license(s) {
89        Ok(Validation::Valid) => unsafe { Ok(parse_license_unchecked(s)) },
90        Err(_) | Ok(Validation::Invalid(_)) => {
91            Err(format!("unable to identify license {s}, please try again!"))
92        }
93    }
94}
95
96fn clap_parse_version(input: &str) -> std::result::Result<PackageReq, String> {
97    PackageReq::from_str(format!("lua {input}").as_str()).map_err(|err| err.to_string())
98}
99
100fn clap_parse_list(input: &str) -> std::result::Result<Vec<String>, String> {
101    if let Some((pos, char)) = input
102        .chars()
103        .find_position(|&c| c != '-' && c != '_' && c != ',' && c.is_ascii_punctuation())
104    {
105        Err(format!(
106            r#"Unexpected punctuation '{char}' found at column {pos}.
107    Lists are comma separated but names should not contain punctuation!"#
108        ))
109    } else {
110        Ok(input.split(',').map(|str| str.trim().to_string()).collect())
111    }
112}
113
114fn parse_license(input: &str) -> Option<LicenseId> {
115    spdx::license_id(input).or_else(|| spdx::imprecise_license_id(input).map(|li| li.0))
116}
117
118/// Parses a license.
119///
120/// # Security
121///
122/// WARNING: This should only be invoked after validating the license with [`validate_license`].
123unsafe fn parse_license_unchecked(input: &str) -> LicenseId {
124    parse_license(input).unwrap_unchecked()
125}
126
127fn validate_license(input: &str) -> std::result::Result<Validation, Box<dyn Error + Send + Sync>> {
128    if input == "none" {
129        return Ok(Validation::Valid);
130    }
131
132    Ok(
133        match parse_license(input).ok_or(format!(
134            r#"Unable to identify SPDX license '{input}', please try again!
135Supported SPDX license IDs are:
136{}
137            "#,
138            spdx::identifiers::LICENSES
139                .iter()
140                .map(|license| license.name)
141                .join(", ")
142        )) {
143            Ok(_) => Validation::Valid,
144            Err(err) => Validation::Invalid(err.into()),
145        },
146    )
147}
148
149pub async fn write_project_rockspec(cli_flags: NewProject, config: Config) -> Result<()> {
150    let project = Project::from_exact(cli_flags.target.clone())?;
151    let render_config = RenderConfig::default_colored()
152        .with_prompt_prefix(Styled::new(">").with_fg(inquire::ui::Color::LightGreen));
153
154    // If the project already exists then ask for override confirmation
155    if project.is_some() && (config.no_prompt() || !overwrite_prompt_confirmed(render_config)?) {
156        return Err(miette!("cancelled creation of project (already exists)"));
157    };
158
159    let validated = match cli_flags {
160        // If all parameters are provided then don't bother prompting the user
161        NewProject {
162            description: Some(description),
163            main: Some(main),
164            labels: Some(labels),
165            lua_versions: Some(lua_versions),
166            maintainer: Some(maintainer),
167            name: Some(name),
168            license,
169            target,
170        } => Ok::<_, miette::Report>(NewProjectValidated {
171            description,
172            labels,
173            license,
174            lua_versions,
175            main,
176            maintainer,
177            name,
178            target,
179        }),
180
181        NewProject {
182            description,
183            labels,
184            license,
185            lua_versions,
186            main,
187            maintainer,
188            name,
189            target,
190        } => {
191            let mut spinner = Spinner::new(
192                Spinners::Dots,
193                "Fetching remote repository metadata... ".into(),
194            );
195
196            let repo_metadata = match github_metadata::get_metadata_for(Some(&target)).await {
197                Ok(value) => value.map_or_else(|| RepoMetadata::default(&target), Ok),
198                Err(_) => {
199                    println!("Could not fetch remote repo metadata, defaulting to empty values.");
200
201                    RepoMetadata::default(&target)
202                }
203            }
204            .into_diagnostic()?;
205
206            spinner.stop_and_persist("✔", "Fetched remote repository metadata.".into());
207
208            let package_name = name
209                .map_or_else(
210                    || {
211                        Text::new("Package name:")
212                            .with_default(&repo_metadata.name)
213                            .with_help_message(
214                                "A folder with the same name will be created for you.",
215                            )
216                            .with_render_config(render_config)
217                            .prompt()
218                    },
219                    Ok,
220                )
221                .into_diagnostic()?;
222
223            let description = description
224                .map_or_else(
225                    || {
226                        Text::new("Description:")
227                            .with_default(&repo_metadata.description.unwrap_or_default())
228                            .with_render_config(render_config)
229                            .prompt()
230                    },
231                    Ok,
232                )
233                .into_diagnostic()?;
234
235            let license = license.map_or_else(
236                || {
237                    Ok::<_, miette::Report>(
238                        match Text::new("License:")
239                            .with_default(&repo_metadata.license.unwrap_or("none".into()))
240                            .with_help_message("Type 'none' for no license")
241                            .with_validator(validate_license)
242                            .with_render_config(render_config)
243                            .prompt()
244                            .into_diagnostic()?
245                            .as_str()
246                        {
247                            "none" => None,
248                            license => unsafe { Some(parse_license_unchecked(license)) },
249                        },
250                    )
251                },
252                |license| Ok(Some(license)),
253            )?;
254
255            let labels = labels.or(repo_metadata.labels).map_or_else(
256                || {
257                    Ok::<_, miette::Report>(
258                        Text::new("Labels:")
259                            .with_placeholder("web,filesystem")
260                            .with_help_message("Labels are comma separated")
261                            .prompt()
262                            .into_diagnostic()?
263                            .split(',')
264                            .map(|label| label.trim().to_string())
265                            .collect_vec(),
266                    )
267                },
268                Ok,
269            )?;
270
271            let maintainer = maintainer
272                .map_or_else(
273                    || {
274                        let prompt = Text::new("Maintainer:");
275                        if let Some(default_maintainer) = repo_metadata
276                            .contributors
277                            .first()
278                            .cloned()
279                            .or_else(|| whoami::realname().ok())
280                        {
281                            prompt.with_default(&default_maintainer).prompt()
282                        } else {
283                            prompt.prompt()
284                        }
285                    },
286                    Ok,
287                )
288                .into_diagnostic()?;
289
290            let lua_versions = lua_versions.map_or_else(
291                || {
292                    Ok::<_, miette::Report>(
293                        format!(
294                            "lua >= {}",
295                            Select::new(
296                                "What is the lowest Lua version you support?",
297                                vec!["5.1", "5.2", "5.3", "5.4", "5.5"]
298                            )
299                            .without_filtering()
300                            .with_vim_mode(true)
301                            .with_help_message(
302                                "This is equivalent to the 'lua >= {version}' constraint."
303                            )
304                            .prompt()
305                            .into_diagnostic()?
306                        )
307                        .parse()?,
308                    )
309                },
310                Ok,
311            )?;
312
313            Ok(NewProjectValidated {
314                target,
315                name: package_name,
316                description,
317                labels,
318                license,
319                lua_versions,
320                maintainer,
321                main: main.unwrap_or(SourceDirType::Src),
322            })
323        }
324    }?;
325
326    let _ = std::fs::create_dir_all(&validated.target).into_diagnostic();
327
328    let rocks_path = validated.target.join(PROJECT_TOML);
329
330    std::fs::write(
331        &rocks_path,
332        format!(
333            r#"
334package = "{package_name}"
335version = "0.1.0"
336lua = "{lua_version_req}"
337
338[description]
339summary = "{summary}"
340maintainer = "{maintainer}"
341labels = [ {labels} ]
342{license}
343
344[dependencies]
345# Add your dependencies here
346# `busted = ">=2.0"`
347
348[run]
349args = [ "{main}/main.lua" ]
350
351[build]
352type = "builtin"
353    "#,
354            package_name = validated.name,
355            summary = validated.description,
356            license = validated
357                .license
358                .map(|license| format!(r#"license = "{}""#, license.name))
359                .unwrap_or_default(),
360            maintainer = validated.maintainer,
361            labels = validated
362                .labels
363                .into_iter()
364                .map(|label| "\"".to_string() + &label + "\"")
365                .join(", "),
366            lua_version_req = validated.lua_versions.version_req(),
367            main = validated.main,
368        )
369        .trim(),
370    )
371    .into_diagnostic()?;
372
373    let main_dir = validated.target.join(validated.main.to_string());
374    if main_dir.exists() {
375        eprintln!(
376            "Directory `{}/` already exists - we won't make any changes to it.",
377            main_dir.display()
378        );
379    } else {
380        std::fs::create_dir(&main_dir).into_diagnostic()?;
381        std::fs::write(main_dir.join("main.lua"), r#"print("Hello world!")"#).into_diagnostic()?;
382    }
383
384    println!("All done!");
385
386    Ok(())
387}
388
389fn overwrite_prompt_confirmed<'a>(render_config: RenderConfig<'a>) -> Result<bool> {
390    Confirm::new("Target directory already has a project, write anyway?")
391        .with_default(false)
392        .with_help_message(&format!("This may overwrite your existing {PROJECT_TOML}",))
393        .with_render_config(render_config)
394        .prompt()
395        .into_diagnostic()
396}
397
398#[cfg(test)]
399mod test {
400    use super::*;
401
402    #[test]
403    fn test_parse_license() {
404        // non-regression for #1612
405        assert!(parse_license("EUPL-1.2").is_some());
406        assert!(parse_license("agplv3").is_some());
407    }
408}
409
410// TODO(vhyrro): Add more tests