mod common;
use oneyaml::{Document, Settings, Value, path};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const COMPOSE: &str = "\
# Local development stack.
x-common: &common
restart: unless-stopped
environment:
TZ: UTC
LOG_LEVEL: info # raise to debug when chasing a bug
logging:
driver: json-file
services:
web:
<<: *common
image: nginx:1.27
ports:
- \"8080:80\"
api:
<<: *common
image: example/api:2.3
restart: always # overrides x-common
";
const EDITED: &str = "\
# Local development stack.
# Start it with: docker compose up
x-common: &common
restart: unless-stopped
environment:
TZ: UTC
LOG_LEVEL: debug # back to info before merging
logging: &logging
driver: json-file
services:
web:
<<: *common
image: nginx:1.27
ports:
- \"8080:80\"
restart: 'no'
api:
<<: *common
image: example/api:2.3
restart: always # overrides x-common
# Background jobs: the api image, run as a queue worker.
worker:
<<: *common
image: example/api:2.3
command:
- worker
- '--queue'
- default
cron:
image: example/cron:1.0
logging: *logging
";
const OWNERS_BEFORE: &[&str] = &[
"# Local development stack. -> document header (rule 2)",
"# raise to debug when chasing a bug -> trailing of `LOG_LEVEL` (rule 5)",
"# overrides x-common -> trailing of `restart` (rule 5)",
];
const OWNERS_AFTER: &[&str] = &[
"# Local development stack. -> document header (rule 2)",
"# Start it with: docker compose up -> document header (rule 2)",
"# back to info before merging -> trailing of `LOG_LEVEL` (rule 5)",
"# overrides x-common -> trailing of `restart` (rule 5)",
"# Background jobs: the api image, run as a queue worker. -> leading of `worker` (rule 4)",
];
#[derive(Serialize)]
struct NewService {
image: &'static str,
command: [&'static str; 3],
}
#[derive(Serialize)]
struct CronService {
image: &'static str,
}
#[derive(Deserialize, Debug, PartialEq)]
struct Service {
image: String,
restart: Option<String>,
#[serde(default)]
environment: BTreeMap<String, String>,
logging: Option<Logging>,
}
#[derive(Deserialize, Debug, PartialEq)]
struct Logging {
driver: String,
}
#[derive(Deserialize)]
struct Compose {
services: BTreeMap<String, Service>,
}
fn edit(settings: &Settings) -> Result<Document> {
let mut doc = Document::parse_with(COMPOSE, settings)?;
assert_eq!(common::owners(&doc)?, OWNERS_BEFORE);
let restart = doc
.get_path(&path!["services", "web", "restart"])
.ok_or("no restart")?;
assert_eq!(restart.text(), "unless-stopped");
let common = doc.id_of(&path!["x-common"]).ok_or("no x-common")?;
assert_eq!(doc.aliases_of(common)?.len(), 2);
doc.set_path(
&path!["services", "web", "environment", "LOG_LEVEL"],
"debug",
)?;
doc.get_path_mut(&path!["x-common", "environment", "LOG_LEVEL"])?
.set_trailing(Some("back to info before merging"))?;
doc.set_path(&path!["services", "web", "restart"], "no")?;
let mut services = doc.get_mut("services")?;
let mut worker = services.insert(
"worker",
&NewService {
image: "example/api:2.3",
command: ["worker", "--queue", "default"],
},
)?;
worker.insert_merge(&[common])?;
let before = worker.document().to_string();
let err = worker
.insert_merge(&[common])
.err()
.ok_or("a second merge key was accepted")?;
assert!(matches!(err, oneyaml::EditError::KeyExists { .. }));
assert_eq!(err.at(), Some(&path!["<<"]));
assert_eq!(worker.document().to_string(), before);
worker.set_leading(["Background jobs: the api image, run as a queue worker."])?;
assert_eq!(doc.aliases_of(common)?.len(), 3);
let logging = doc
.get_path_mut(&path!["x-common", "logging"])?
.set_anchor(Some("logging"))?
.id();
doc.get_mut("services")?
.insert(
"cron",
&CronService {
image: "example/cron:1.0",
},
)?
.insert_alias("logging", logging)?;
assert_eq!(doc.aliases_of(logging)?.len(), 1);
let header: Vec<String> = doc.header()?.iter().map(|c| c.text().to_owned()).collect();
assert_eq!(header, ["Local development stack."]);
doc.set_header([
"Local development stack.",
"Start it with: docker compose up",
])?;
assert_eq!(common::owners(&doc)?, OWNERS_AFTER);
Ok(doc)
}
fn run() -> Result<String> {
let settings = Settings::new().merge_keys(true);
assert_eq!(
Document::parse_with(COMPOSE, &settings)?
.to_string()
.as_bytes(),
COMPOSE.as_bytes()
);
let doc = edit(&settings)?;
let text = doc.to_string();
assert_eq!(text, EDITED);
let from_serde: Value = oneyaml::from_str_with(&text, &settings)?;
assert_eq!(from_serde, doc.to_value()?);
let compose: Compose = doc.deserialize()?;
let typed: Compose = oneyaml::from_str_with(&text, &settings)?;
assert_eq!(compose.services, typed.services);
let service = |name: &str| compose.services.get(name).ok_or("no such service");
let (web, api, worker) = (service("web")?, service("api")?, service("worker")?);
let cron = service("cron")?;
assert_eq!(web.restart.as_deref(), Some("no"));
assert_eq!(api.restart.as_deref(), Some("always"));
assert_eq!(worker.restart.as_deref(), Some("unless-stopped"));
assert_eq!(cron.restart, None);
assert!(cron.environment.is_empty());
for s in [web, api, worker] {
assert_eq!(
s.environment.get("LOG_LEVEL").map(String::as_str),
Some("debug")
);
assert_eq!(s.logging, cron.logging);
}
assert_eq!(
cron.logging,
Some(Logging {
driver: "json-file".to_owned()
})
);
let plain: Value = oneyaml::from_str(&text)?;
let merge = plain
.get("services")
.and_then(|s| s.get("api"))
.and_then(|api| api.get("<<"));
assert!(merge.is_some_and(Value::is_mapping));
Ok(text)
}
#[cfg_attr(test, allow(dead_code))]
fn main() -> Result<()> {
print!("{}", run()?);
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn compose_merge() -> super::Result<()> {
super::run().map(|_| ())
}
}