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