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#[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 target: PathBuf,
45
46 #[arg(long)]
48 name: Option<String>,
49
50 #[arg(long)]
52 description: Option<String>,
53
54 #[arg(long, value_parser = clap_parse_license)]
56 license: Option<LicenseId>,
57
58 #[arg(long)]
60 maintainer: Option<String>,
61
62 #[arg(long, value_parser = clap_parse_list)]
64 labels: Option<std::vec::Vec<String>>, #[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
117unsafe 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 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 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 assert!(parse_license("EUPL-1.2").is_some());
404 assert!(parse_license("agplv3").is_some());
405 }
406}
407
408