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#[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 target: PathBuf,
46
47 #[arg(long)]
49 name: Option<String>,
50
51 #[arg(long)]
53 description: Option<String>,
54
55 #[arg(long, value_parser = clap_parse_license)]
57 license: Option<LicenseId>,
58
59 #[arg(long)]
61 maintainer: Option<String>,
62
63 #[arg(long, value_parser = clap_parse_list)]
65 labels: Option<std::vec::Vec<String>>, #[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
118unsafe 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 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 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 assert!(parse_license("EUPL-1.2").is_some());
406 assert!(parse_license("agplv3").is_some());
407 }
408}
409
410