Skip to content

Commit 33f3ecc

Browse files
authored
Package and export configuration files from plugins (#2044)
This change allows plugins to package and declare exported configuration files using the syntax below: ```toml [plugins.definitions.some-plugin] # Paths are relative to this TOML file location (plugin.toml, source.toml, etc) exported_config_paths = ["relative/path/to/config.yml", "other/config.alternate.yml"] # ... ``` Declaring an exported configuration allows a configuration file to be automatically included at runtime as a fallback default if no other configuration files are present or detected by the workspace (including `.qlty/configs` directory). The configuration file will be symlinked just like any other configuration. ### Disabling Export Behavior from a Downstream Consumer Users who want to opt out of automatic importing of configuration files can either: 1. Define a default configuration file in their workspace source tree (or `.qlty/configs`), or, 2. Override the `plugins.definitions.<plugin>.exported_config_paths` as: ```toml [plugins.definitions.some-plugin] exported_config_paths = [] ``` ### Using with Sources This behavior is can used with custom sources to automatically include custom configuration from a given layered source: #### \> .qlty/qlty.toml ```toml [[source]] name = "default" default = true [[source]] name = "configs" directory = "./configuration" # this can also be a repository ``` #### \> configuration/source.toml ```toml [plugins.definitions.rubocop] exported_config_paths = ["rubocop/.rubocop.yml"] ``` #### \> configuration/rubocop/.rubocop.yml ```yaml AllCops: Enable: true # ... ``` Any project including the above source will automatically receive custom rubocop configuration that will automatically be applied when running `qlty check`.
1 parent 947f854 commit 33f3ecc

File tree

15 files changed

+330
-68
lines changed

15 files changed

+330
-68
lines changed

qlty-check/src/executor.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use qlty_types::analysis::v1::{Issue, Message, MessageLevel};
2929
use rand::seq::SliceRandom;
3030
use rand::thread_rng;
3131
use rayon::prelude::*;
32+
use staging_area::load_config_file_from_source;
3233
use std::collections::{HashMap, HashSet};
3334
use std::path::{Path, PathBuf};
3435
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -291,6 +292,14 @@ impl Executor {
291292
}
292293
}
293294

295+
let exported_config_paths = self
296+
.plan
297+
.invocations
298+
.iter()
299+
.flat_map(|invocation| invocation.plugin.exported_config_paths.clone())
300+
.unique()
301+
.collect_vec();
302+
294303
debug!(
295304
"Walker found {} config and affects cache files in {:.2}s",
296305
repository_config_files.len(),
@@ -318,7 +327,7 @@ impl Executor {
318327
if self.plan.workspace.root != self.plan.staging_area.destination_directory {
319328
// for formatters
320329
let loaded_config_file = load_config_file_from_qlty_dir(
321-
&PathBuf::from(config_file),
330+
config_file,
322331
&self.plan.workspace,
323332
&self.plan.staging_area.destination_directory,
324333
)?;
@@ -330,7 +339,7 @@ impl Executor {
330339

331340
// for linters
332341
let loaded_config_file = load_config_file_from_qlty_dir(
333-
&PathBuf::from(config_file),
342+
config_file,
334343
&self.plan.workspace,
335344
&self.plan.workspace.root,
336345
)?;
@@ -340,6 +349,28 @@ impl Executor {
340349
}
341350
}
342351

352+
for config_file in &exported_config_paths {
353+
if self.plan.workspace.root != self.plan.staging_area.destination_directory {
354+
// for formatters
355+
let loaded_config_file = load_config_file_from_source(
356+
config_file,
357+
&self.plan.staging_area.destination_directory,
358+
)?;
359+
360+
if !loaded_config_file.is_empty() {
361+
loaded_config_files.push(loaded_config_file);
362+
}
363+
}
364+
365+
// for linters
366+
let loaded_config_file =
367+
load_config_file_from_source(&config_file, &self.plan.workspace.root)?;
368+
369+
if !loaded_config_file.is_empty() {
370+
loaded_config_files.push(loaded_config_file);
371+
}
372+
}
373+
343374
debug!(
344375
"Staged {} config files in {:.2}s",
345376
repository_config_files.len(),

qlty-check/src/executor/staging_area.rs

Lines changed: 153 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -218,86 +218,71 @@ impl StagingArea {
218218
}
219219

220220
pub fn load_config_file_from_repository(
221-
config_file: &Path,
221+
config_file: impl AsRef<Path>,
222222
workspace: &Workspace,
223-
destination: &Path,
224-
) -> Result<()> {
225-
let to = destination.join(config_file.strip_prefix(&workspace.root).unwrap());
226-
227-
if to.exists() {
228-
debug!("Config file already exists in workspace: {:?}", to);
229-
return Ok(());
230-
}
231-
232-
let to_dir = to.parent();
233-
234-
if !to_dir.unwrap().exists() {
235-
debug!("Creating destination dir: {:?}", destination);
236-
create_dir_all(to_dir.unwrap()).with_context(|| {
237-
format!(
238-
"Failed to create workspace entries destination dir: {}",
239-
destination.display()
240-
)
241-
})?;
242-
}
223+
destination: impl AsRef<Path>,
224+
) -> Result<String> {
225+
let to = destination
226+
.as_ref()
227+
.join(config_file.as_ref().strip_prefix(&workspace.root).unwrap());
243228

244-
debug!(
245-
"Copying config file from repository: {:?} -> {:?}",
246-
config_file, to
247-
);
248-
copy(config_file, to.clone()).with_context(|| {
249-
format!(
250-
"Failed to copy config file {} to {}",
251-
config_file.display(),
252-
to.display()
253-
)
254-
})?;
229+
load_config_file_from(config_file, to)
230+
}
255231

256-
Ok(())
232+
pub fn load_config_file_from_source(
233+
config_file: impl AsRef<Path>,
234+
destination: impl AsRef<Path>,
235+
) -> Result<String> {
236+
load_config_file_from(
237+
&config_file,
238+
destination
239+
.as_ref()
240+
.join(config_file.as_ref().file_name().unwrap()),
241+
)
257242
}
258243

259244
pub fn load_config_file_from_qlty_dir(
260-
config_file: &Path,
245+
config_file: impl AsRef<Path>,
261246
workspace: &Workspace,
262-
destination: &Path,
247+
destination: impl AsRef<Path>,
263248
) -> Result<String> {
264-
let config_file_name = config_file.file_name().unwrap();
249+
let config_file_name = config_file.as_ref().file_name().unwrap();
265250
let from = workspace.library()?.configs_dir().join(config_file_name);
251+
let to = destination.as_ref().join(config_file_name);
252+
load_config_file_from(from, to)
253+
}
266254

267-
if from.exists() {
268-
let to = destination.join(config_file_name);
269-
if to.exists() {
270-
debug!("Config file already exists in workspace: {:?}", to);
271-
return Ok(to.display().to_string());
272-
}
255+
fn load_config_file_from(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<String> {
256+
let from = from.as_ref();
257+
let to = to.as_ref();
258+
if !from.exists() {
259+
return Ok("".to_string());
260+
} else if to.exists() {
261+
debug!("Config file already exists in workspace: {:?}", to);
262+
return Ok(to.display().to_string());
263+
}
273264

274-
debug!(
275-
"Symlinking config file from qlty dir: {:?} -> {:?}",
276-
from, to
277-
);
265+
debug!("Symlinking config file: {:?} -> {:?}", from, to);
278266

279-
let result: std::io::Result<_>;
280-
#[cfg(windows)]
281-
{
282-
result = std::os::windows::fs::symlink_file(from.clone(), to.clone());
283-
}
284-
#[cfg(unix)]
285-
{
286-
result = std::os::unix::fs::symlink(from.clone(), to.clone());
287-
}
267+
symlink(from, to).with_context(|| {
268+
format!(
269+
"Failed to symlink config file {} to {}",
270+
from.display(),
271+
to.display()
272+
)
273+
})?;
288274

289-
result.with_context(|| {
290-
format!(
291-
"Failed to symlink config file {} to {}",
292-
from.display(),
293-
to.display()
294-
)
295-
})?;
275+
Ok(to.display().to_string())
276+
}
296277

297-
return Ok(to.display().to_string());
298-
}
278+
#[cfg(windows)]
279+
fn symlink(from: &Path, to: &Path) -> std::io::Result<()> {
280+
std::os::windows::fs::symlink_file(from, to)
281+
}
299282

300-
Ok("".to_string())
283+
#[cfg(unix)]
284+
fn symlink(from: &Path, to: &Path) -> std::io::Result<()> {
285+
std::os::unix::fs::symlink(from, to)
301286
}
302287

303288
#[cfg(test)]
@@ -387,4 +372,107 @@ mod test {
387372
let clone = stage.clone();
388373
assert_eq!(clone.read("test".into()).unwrap(), "expected");
389374
}
375+
376+
#[test]
377+
fn test_load_config_file_from_repository() {
378+
let (_, paths) = new_staging_area(Mode::ReadWrite);
379+
380+
let config_file = paths.source.path().join("abc").join("conf.yml");
381+
create_dir_all(config_file.parent().unwrap()).unwrap();
382+
create_dir_all(paths.dest.path().join("abc")).unwrap();
383+
std::fs::write(&config_file, "repository_config_content").unwrap();
384+
385+
let workspace = Workspace::for_root(paths.source.path()).unwrap();
386+
let result = load_config_file_from_repository(&config_file, &workspace, paths.dest.path());
387+
assert!(result.is_ok());
388+
389+
let dest_file = paths.dest.path().join("abc").join("conf.yml");
390+
assert!(dest_file.exists());
391+
392+
let content = std::fs::read_to_string(&dest_file).unwrap();
393+
assert_eq!(content, "repository_config_content");
394+
}
395+
396+
#[test]
397+
fn test_load_config_file_from_symlink_fail() {
398+
let (_, paths) = new_staging_area(Mode::ReadWrite);
399+
400+
let config_file = paths.source.path().join("abc").join("conf.yml");
401+
create_dir_all(config_file.parent().unwrap()).unwrap();
402+
403+
std::fs::write(&config_file, "repository_config_content").unwrap();
404+
405+
let workspace = Workspace::for_root(paths.source.path()).unwrap();
406+
let result = load_config_file_from_repository(&config_file, &workspace, paths.dest.path());
407+
assert!(result.is_err());
408+
409+
let dest_file = paths.dest.path().join("abc").join("conf.yml");
410+
assert!(!dest_file.exists());
411+
}
412+
413+
#[test]
414+
fn test_load_config_file_from_source() {
415+
let (_, paths) = new_staging_area(Mode::ReadWrite);
416+
417+
let config_file = paths.source.path().join("nested").join("conf.yml");
418+
create_dir_all(config_file.parent().unwrap()).unwrap();
419+
std::fs::write(&config_file, "source_config_content").unwrap();
420+
421+
let result = load_config_file_from_source(&config_file, paths.dest.path());
422+
assert!(result.is_ok());
423+
424+
let dest_file = paths.dest.path().join("conf.yml");
425+
assert!(dest_file.exists());
426+
427+
let content = std::fs::read_to_string(&dest_file).unwrap();
428+
assert_eq!(content, "source_config_content");
429+
}
430+
431+
#[test]
432+
fn test_load_config_file_from_qlty_dir() {
433+
let (_, paths) = new_staging_area(Mode::ReadWrite);
434+
let mock_workspace_path = tempdir().unwrap();
435+
let configs_dir = mock_workspace_path.path().join(".qlty").join("configs");
436+
create_dir_all(&configs_dir).unwrap();
437+
438+
let config_file = configs_dir.join("conf.yml");
439+
std::fs::write(&config_file, "qlty_dir_config_content").unwrap();
440+
441+
let workspace = Workspace::for_root(mock_workspace_path.path()).unwrap();
442+
let result = load_config_file_from_qlty_dir("conf.yml", &workspace, paths.dest.path());
443+
assert!(result.is_ok());
444+
445+
let dest_file = paths.dest.path().join("conf.yml");
446+
assert!(dest_file.exists());
447+
448+
let content = std::fs::read_to_string(&dest_file).unwrap();
449+
assert_eq!(content, "qlty_dir_config_content");
450+
}
451+
452+
#[test]
453+
fn test_load_config_file_nonexistent() {
454+
let (_, paths) = new_staging_area(Mode::ReadWrite);
455+
let nonexistent_file = paths.source.path().join("conf.yml");
456+
let result = load_config_file_from(&nonexistent_file, paths.dest.path().join("conf.yml"));
457+
458+
assert!(result.is_ok());
459+
assert_eq!(result.unwrap(), "");
460+
}
461+
462+
#[test]
463+
fn test_load_config_file_already_exists() {
464+
let (_, paths) = new_staging_area(Mode::ReadWrite);
465+
let source_file = paths.source.path().join("conf.yml");
466+
let dest_file = paths.dest.path().join("conf.yml");
467+
468+
std::fs::write(&source_file, "source_content").unwrap();
469+
std::fs::write(&dest_file, "destination_content").unwrap();
470+
471+
let result = load_config_file_from(&source_file, &dest_file);
472+
assert!(result.is_ok());
473+
assert_eq!(result.unwrap(), dest_file.display().to_string());
474+
475+
let content = std::fs::read_to_string(&dest_file).unwrap();
476+
assert_eq!(content, "destination_content");
477+
}
390478
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.qlty/results
2+
.qlty/logs
3+
.qlty/out
4+
.qlty/sources
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
config_version = "0"
2+
3+
[[source]]
4+
name = "config-exporter"
5+
directory = "config-exporter"
6+
7+
[[plugin]]
8+
name = "config-exporter"
9+
version = "1.0.0"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
configuration: true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sample.sh:2:1: ERROR invalid line
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
config_version = "0"
2+
3+
[plugins.definitions.config-exporter]
4+
file_types = ["shell"]
5+
config_files = ["config.yml", "config.alternate.yml", "results.txt"]
6+
exported_config_paths = [
7+
"config.yml",
8+
"results.txt",
9+
"subdir/config.alternate.yml",
10+
]
11+
12+
[plugins.definitions.config-exporter.drivers.lint]
13+
prepare_script = "mkdir ${linter} && echo dir %2 > ${linter}/ls.cmd || echo dir %2 > ${linter}/ls.cmd"
14+
script = "ls -l config.yml"
15+
success_codes = [0]
16+
output = "pass_fail"
17+
18+
[plugins.definitions.config-exporter.drivers.lint2]
19+
prepare_script = "mkdir ${linter} && echo dir %2 > ${linter}/ls.cmd || echo dir %2 > ${linter}/ls.cmd"
20+
script = "ls -l config.alternate.yml"
21+
success_codes = [0]
22+
output = "pass_fail"
23+
24+
[plugins.definitions.config-exporter.drivers.lint3]
25+
prepare_script = "mkdir ${linter} && echo type %1 > ${linter}/cat.cmd || echo type %1 > ${linter}/cat.cmd"
26+
script = "cat results.txt"
27+
output_format = "regex"
28+
output_regex = "((?P<path>.*):(?P<line>-?\\d+):(?P<col>-?\\d+): (?P<code>\\S+) (?P<message>.+))\n"
29+
success_codes = [0]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
configuration: true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sample.sh:2:1: OVERRIDE valid line
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh
2+
echo hi

0 commit comments

Comments
 (0)