aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/ts/tskm/src/interface
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-04-04 11:48:44 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-04-04 11:48:44 +0200
commit135d09bfb305d54cac1ba1fb9861d5b9309a7b3a (patch)
tree459109a40320530993ae560f55a730a72df31416 /pkgs/by-name/ts/tskm/src/interface
parentrefactor(modules/legacy/firefox): Move to by-name (diff)
downloadnixos-config-135d09bfb305d54cac1ba1fb9861d5b9309a7b3a.zip
feat(pkgs/neorg): Rewrite in rust
This improves upon neorg by integrating it better into the system context.
Diffstat (limited to 'pkgs/by-name/ts/tskm/src/interface')
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/handle.rs112
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/mod.rs257
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/mod.rs4
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs79
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs18
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/open/handle.rs137
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/open/mod.rs2
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/project/handle.rs87
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/project/mod.rs73
9 files changed, 769 insertions, 0 deletions
diff --git a/pkgs/by-name/ts/tskm/src/interface/input/handle.rs b/pkgs/by-name/ts/tskm/src/interface/input/handle.rs
new file mode 100644
index 00000000..0ff0e56e
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/input/handle.rs
@@ -0,0 +1,112 @@
+use std::{
+ fs, process,
+ str::FromStr,
+ thread::{self, sleep},
+ time::Duration,
+};
+
+use anyhow::{Context, Result};
+use log::{error, info};
+
+use crate::cli::InputCommand;
+
+use super::Input;
+
+/// # Errors
+/// When command handling fails.
+///
+/// # Panics
+/// When internal assertions fail.
+pub fn handle(command: InputCommand) -> Result<()> {
+ match command {
+ InputCommand::Add { inputs } => {
+ for input in inputs {
+ input.commit().with_context(|| {
+ format!("Failed to add input ('{input}') to the input storage.")
+ })?;
+ }
+ }
+ InputCommand::Remove { inputs } => {
+ for input in inputs {
+ input.remove().with_context(|| {
+ format!("Failed to remove input ('{input}') from the input storage.")
+ })?;
+ }
+ }
+ InputCommand::File { file } => {
+ let file = fs::read_to_string(file)?;
+ for line in file.lines() {
+ let input = Input::from_str(line)?;
+ input.commit().with_context(|| {
+ format!("Failed to add input ('{input}') to the input storage.")
+ })?;
+ }
+ }
+ InputCommand::Review { project } => {
+ let project = project.to_project_display();
+
+ let local_project = project.clone();
+ let handle = thread::spawn(move || {
+ // We assume that the project is not yet open.
+ let mut firefox = process::Command::new("firefox")
+ .args(["-P", local_project.as_str(), "about:newtab"])
+ .spawn()?;
+
+ Ok::<_, anyhow::Error>(firefox.wait()?)
+ });
+ // Give Firefox some time to start.
+ info!("Waiting on firefox to start");
+ sleep(Duration::from_secs(4));
+
+ let project_str = project.as_str();
+ 'outer: for all in Input::all()?.chunks(100) {
+ info!("Starting review for the first hundred URLs.");
+
+ for input in all {
+ info!("-> '{input}'");
+ let status = process::Command::new("firefox")
+ .args(["-P", project_str, input.url().to_string().as_str()])
+ .status()?;
+
+ if status.success() {
+ input.remove()?;
+ } else {
+ error!("Adding `{input}` to Firefox failed!");
+ }
+ }
+
+ {
+ use std::io::{stdin, stdout, Write};
+
+ let mut s = String::new();
+ eprint!("Continue? (y/N) ");
+ stdout().flush()?;
+
+ stdin()
+ .read_line(&mut s)
+ .expect("Did not enter a correct string");
+
+ if let Some('\n') = s.chars().next_back() {
+ s.pop();
+ }
+ if let Some('\r') = s.chars().next_back() {
+ s.pop();
+ }
+
+ if s != "y" {
+ break 'outer;
+ }
+ }
+ }
+
+ info!("Waiting for firefox to stop");
+ handle.join().expect("Should be joinable")?;
+ }
+ InputCommand::List => {
+ for url in Input::all()? {
+ println!("{url}");
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/pkgs/by-name/ts/tskm/src/interface/input/mod.rs b/pkgs/by-name/ts/tskm/src/interface/input/mod.rs
new file mode 100644
index 00000000..9ece7a3a
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/input/mod.rs
@@ -0,0 +1,257 @@
+use std::{
+ collections::HashSet,
+ fmt::Display,
+ fs::{self, read_to_string, File},
+ io::Write,
+ path::PathBuf,
+ process::Command,
+ str::FromStr,
+};
+
+use anyhow::{bail, Context, Result};
+use url::Url;
+use walkdir::WalkDir;
+
+pub mod handle;
+pub use handle::handle;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct NoWhitespaceString(String);
+
+impl NoWhitespaceString {
+ /// # Panics
+ /// If the input contains whitespace.
+ #[must_use]
+ pub fn new(input: String) -> Self {
+ if input.contains(' ') {
+ panic!("Your input '{input}' contains whitespace. I did not expect that.")
+ } else {
+ Self(input)
+ }
+ }
+}
+
+impl Display for NoWhitespaceString {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl NoWhitespaceString {
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Input {
+ url: Url,
+ tags: HashSet<NoWhitespaceString>,
+}
+
+impl FromStr for Input {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
+ if s.contains(' ') {
+ let (url, tags) = s.split_once(' ').expect("Should work");
+ Ok(Self {
+ url: Url::from_str(url)?,
+ tags: {
+ tags.trim()
+ .split(' ')
+ .map(|tag| {
+ if let Some(tag) = tag.strip_prefix('+') {
+ Ok(NoWhitespaceString::new(tag.to_owned()))
+ } else {
+ bail!("Your tag '{tag}' does not start with the required '+'");
+ }
+ })
+ .collect::<Result<_, _>>()?
+ },
+ })
+ } else {
+ Ok(Self {
+ url: Url::from_str(s)?,
+ tags: HashSet::new(),
+ })
+ }
+ }
+}
+
+impl Display for Input {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if self.tags.is_empty() {
+ self.url.fmt(f)
+ } else {
+ write!(
+ f,
+ "{} {}",
+ self.url,
+ self.tags
+ .iter()
+ .fold(String::new(), |mut acc, tag| {
+ acc.push('+');
+ acc.push_str(tag.as_str());
+ acc.push(' ');
+ acc
+ })
+ .trim()
+ )
+ }
+ }
+}
+
+impl Input {
+ fn base_path() -> PathBuf {
+ dirs::data_local_dir()
+ .expect("This should be set")
+ .join("tskm/inputs")
+ }
+
+ fn url_path(url: &Url) -> Result<PathBuf> {
+ let base_path = Self::base_path();
+
+ let url_path = base_path.join(url.to_string());
+ fs::create_dir_all(&url_path)
+ .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?;
+
+ Ok(url_path.join("url_value"))
+ }
+
+ #[must_use]
+ pub fn url(&self) -> &Url {
+ &self.url
+ }
+
+ /// Commit this constructed [`Input`] to storage.
+ ///
+ /// # Errors
+ /// If IO operations fail.
+ pub fn commit(&self) -> Result<()> {
+ let url_path = Self::url_path(&self.url)?;
+
+ let url_content = {
+ if url_path.exists() {
+ read_to_string(&url_path)?
+ } else {
+ String::new()
+ }
+ };
+
+ let mut file = File::create(&url_path)
+ .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?;
+ writeln!(file, "{url_content}{self}")?;
+
+ Self::git_commit(&format!("Add new url: '{self}'"))?;
+
+ Ok(())
+ }
+
+ /// Remove this constructed [`Input`] to storage.
+ ///
+ /// Beware that this does not take tags into account.
+ ///
+ /// # Errors
+ /// If IO operations fail.
+ pub fn remove(&self) -> Result<()> {
+ let url_path = Self::url_path(&self.url)?;
+
+ fs::remove_file(&url_path)
+ .with_context(|| format!("Failed to remove file: '{}'", url_path.display()))?;
+
+ let mut url_path = url_path.as_path();
+ while let Some(parent) = url_path.parent() {
+ if fs::read_dir(parent)?.count() == 0 {
+ fs::remove_dir(parent)?;
+ }
+ url_path = parent;
+ }
+
+ Self::git_commit(&format!("Remove url: '{self}'"))?;
+ Ok(())
+ }
+
+ /// Commit your changes
+ fn git_commit(message: &str) -> Result<()> {
+ let status = Command::new("git")
+ .args(["add", "."])
+ .current_dir(Self::base_path())
+ .status()?;
+ if !status.success() {
+ bail!("Git add . failed!");
+ }
+
+ let status = Command::new("git")
+ .args(["commit", "--message", message, "--no-gpg-sign"])
+ .current_dir(Self::base_path())
+ .status()?;
+ if !status.success() {
+ bail!("Git commit failed!");
+ }
+
+ Ok(())
+ }
+
+ /// Get all previously [`Self::commit`]ed inputs.
+ ///
+ /// # Errors
+ /// When IO handling fails.
+ ///
+ /// # Panics
+ /// If internal assertions fail.
+ pub fn all() -> Result<Vec<Self>> {
+ let mut output = vec![];
+ for entry in WalkDir::new(Self::base_path())
+ .min_depth(1)
+ .into_iter()
+ .filter_entry(|e| {
+ let s = e.file_name().to_str();
+ s != Some(".git")
+ })
+ {
+ let entry = entry?;
+
+ if !entry.file_type().is_file() {
+ continue;
+ }
+
+ let url_value_file = entry
+ .path()
+ .to_str()
+ .expect("All of these should be URLs and thus valid strings");
+ assert!(url_value_file.ends_with("/url_value"));
+
+ let url = {
+ let base = url_value_file
+ .strip_prefix(&format!("{}/", Self::base_path().display()))
+ .expect("This will exist");
+
+ let (proto, path) = base.split_once(':').expect("This will countain a :");
+
+ let path = path.strip_suffix("/url_value").expect("Will exist");
+
+ Url::from_str(&format!("{proto}:/{path}"))
+ .expect("This was a URL, it should still be one")
+ };
+ let tags = {
+ let url_values = read_to_string(PathBuf::from(url_value_file))?;
+ url_values
+ .lines()
+ .map(|line| {
+ let input = Self::from_str(line)?;
+ Ok::<_, anyhow::Error>(input.tags)
+ })
+ .collect::<Result<Vec<HashSet<NoWhitespaceString>>, _>>()?
+ .into_iter()
+ .flatten()
+ .collect()
+ };
+
+ output.push(Self { url, tags });
+ }
+
+ Ok(output)
+ }
+}
diff --git a/pkgs/by-name/ts/tskm/src/interface/mod.rs b/pkgs/by-name/ts/tskm/src/interface/mod.rs
new file mode 100644
index 00000000..1a0d934c
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/mod.rs
@@ -0,0 +1,4 @@
+pub mod input;
+pub mod neorg;
+pub mod open;
+pub mod project;
diff --git a/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs
new file mode 100644
index 00000000..ea11f1e2
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs
@@ -0,0 +1,79 @@
+use std::{
+ env,
+ fs::{self, read_to_string, OpenOptions},
+ io::Write,
+ process::Command,
+};
+
+use anyhow::{bail, Result};
+
+use crate::cli::NeorgCommand;
+
+pub fn handle(command: NeorgCommand) -> Result<()> {
+ match command {
+ NeorgCommand::Task { id } => {
+ let project = id.project()?;
+ let path = dirs::data_local_dir()
+ .expect("This should exists")
+ .join("notes")
+ .join(project.get_neorg_path()?);
+
+ fs::create_dir_all(path.parent().expect("This should exist"))?;
+
+ {
+ let contents = read_to_string(&path)?;
+ if contents.contains(format!("% {}", id.to_uuid()?).as_str()) {
+ let mut options = OpenOptions::new();
+ options.append(true).create(true);
+
+ let mut file = options.open(&path)?;
+ file.write_all(format!("* TITLE (% {})", id.to_uuid()?).as_bytes())?;
+ }
+ }
+
+ let editor = env::var("EDITOR").unwrap_or("nvim".to_owned());
+ let status = Command::new(editor)
+ .args([
+ path.to_str().expect("Should be a utf-8 str"),
+ "-c",
+ format!("/% {}", id.to_uuid()?).as_str(),
+ ])
+ .status()?;
+ if !status.success() {
+ bail!("$EDITOR fail with error code: {status}");
+ }
+
+ {
+ let status = Command::new("git")
+ .args(["add", "."])
+ .current_dir(&path)
+ .status()?;
+ if !status.success() {
+ bail!("Git add . failed!");
+ }
+
+ let status = Command::new("git")
+ .args([
+ "commit",
+ "--message",
+ format!(
+ "chore({}): Update",
+ path.parent().expect("Should have a parent").display()
+ )
+ .as_str(),
+ "--no-gpg-sign",
+ ])
+ .current_dir(&path)
+ .status()?;
+ if !status.success() {
+ bail!("Git commit failed!");
+ }
+ }
+
+ {
+ id.annotate("[neorg data]]")?;
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs b/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs
new file mode 100644
index 00000000..dc5cdf19
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs
@@ -0,0 +1,18 @@
+use std::path::PathBuf;
+
+use anyhow::Result;
+
+use crate::task::{run_task, Project};
+
+pub mod handle;
+pub use handle::handle;
+
+impl Project {
+ pub(super) fn get_neorg_path(&self) -> Result<PathBuf> {
+ let project_path = run_task(&[
+ "_get",
+ format!("rc.context.{}.rc.neorg_path", self.to_context_display()).as_str(),
+ ])?;
+ Ok(PathBuf::from(project_path.as_str()))
+ }
+}
diff --git a/pkgs/by-name/ts/tskm/src/interface/open/handle.rs b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs
new file mode 100644
index 00000000..5738d232
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs
@@ -0,0 +1,137 @@
+use std::process;
+
+use anyhow::{bail, Context, Result};
+use log::{error, info};
+
+use crate::{cli::OpenCommand, rofi, task};
+
+pub fn handle(command: OpenCommand) -> Result<()> {
+ match command {
+ OpenCommand::Review => {
+ for project in task::Project::all()? {
+ if project.is_touched() {
+ info!("Reviewing project: '{}'", project.to_project_display());
+ open_in_browser(project).with_context(|| {
+ format!(
+ "Failed to open project ('{}') in Firefox",
+ project.to_project_display()
+ )
+ })?;
+ project.untouch().with_context(|| {
+ format!(
+ "Failed to untouch project ('{}')",
+ project.to_project_display()
+ )
+ })?;
+ }
+ }
+ }
+ OpenCommand::Project { project } => {
+ let project = if let Some(p) = project {
+ p
+ } else if let Some(p) = task::Project::get_current()? {
+ p
+ } else {
+ bail!("You need to either supply a project or have a project active!");
+ };
+
+ project.touch().context("Failed to touch project")?;
+ open_in_browser(&project).context("Failed to open project")?;
+ }
+ OpenCommand::Select => {
+ let selected_project: task::Project = task::Project::from_project_string(
+ &rofi::select(
+ task::Project::all()
+ .context("Failed to get all registered projects")?
+ .iter()
+ .map(task::Project::to_project_display)
+ .collect::<Vec<_>>()
+ .as_slice(),
+ )
+ .context("Failed to get selected project")?,
+ )
+ .expect("This should work, as we send only projects in");
+
+ selected_project
+ .touch()
+ .context("Failed to touch project")?;
+
+ open_in_browser(&selected_project).context("Failed to open project")?;
+ }
+ }
+ Ok(())
+}
+
+fn open_in_browser(selected_project: &task::Project) -> Result<()> {
+ let old_project: Option<task::Project> =
+ task::Project::get_current().context("Failed to get currently active project")?;
+ // We have ensured that only one task may be active
+ let old_task: Option<task::Id> =
+ task::Id::get_current().context("Failed to get currently active task")?;
+
+ selected_project.activate().with_context(|| {
+ format!(
+ "Failed to active project: '{}'",
+ selected_project.to_project_display()
+ )
+ })?;
+
+ let tracking_task = {
+ let all_tasks = selected_project.get_tasks().with_context(|| {
+ format!(
+ "Failed to get assoctiated tasks for project: '{}'",
+ selected_project.to_project_display()
+ )
+ })?;
+
+ let tracking_task = all_tasks.into_iter().find(|t| {
+ let maybe_desc = t.description();
+ if let Ok(desc) = maybe_desc {
+ desc == "tracking"
+ } else {
+ error!(
+ "Getting task description returned error: {}",
+ maybe_desc.expect_err("We already check for Ok")
+ );
+ false
+ }
+ });
+
+ if let Some(task) = tracking_task {
+ info!(
+ "Starting task {} -> tracking",
+ selected_project.to_project_display()
+ );
+ task.start()?;
+ }
+ tracking_task
+ };
+
+ let status = process::Command::new("firefox")
+ .args([
+ "-P",
+ selected_project.to_project_display().as_str(),
+ "about:newtab",
+ ])
+ .status()
+ .context("Failed to start firefox")?;
+
+ if !status.success() {
+ error!("Firefox run exited with error.");
+ }
+
+ if let Some(task) = tracking_task {
+ task.stop()?;
+ }
+ if let Some(task) = old_task {
+ task.start()?;
+ }
+
+ if let Some(project) = old_project {
+ project.activate()?;
+ } else {
+ task::Project::clear()?;
+ }
+
+ Ok(())
+}
diff --git a/pkgs/by-name/ts/tskm/src/interface/open/mod.rs b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs
new file mode 100644
index 00000000..a70793bc
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs
@@ -0,0 +1,2 @@
+pub mod handle;
+pub use handle::handle;
diff --git a/pkgs/by-name/ts/tskm/src/interface/project/handle.rs b/pkgs/by-name/ts/tskm/src/interface/project/handle.rs
new file mode 100644
index 00000000..d1cc0b1e
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/project/handle.rs
@@ -0,0 +1,87 @@
+use std::{env, fs::File, io::Write};
+
+use anyhow::{anyhow, Context, Result};
+use log::trace;
+
+use crate::{cli::ProjectCommand, task};
+
+use super::{ProjectDefinition, ProjectList, SortAlphabetically};
+
+/// # Panics
+/// If internal expectations fail.
+///
+/// # Errors
+/// If IO operations fail.
+pub fn handle(command: ProjectCommand) -> Result<()> {
+ match command {
+ ProjectCommand::List => {
+ for project in task::Project::all()? {
+ println!("'{}'", project.to_project_display());
+ }
+ }
+ ProjectCommand::Add {
+ mut new_project_name,
+ } => {
+ let project_file = env::var("TSKM_PROJECT_FILE")
+ .map_err(|err| anyhow!("The `TSKM_PROJECT_FILE` env var is unset: {err}"))?;
+
+ let mut projects_content: ProjectList =
+ serde_json::from_reader(File::open(&project_file).with_context(|| {
+ format!("Failed to open project file ('{project_file:?}') for reading")
+ })?)?;
+
+ let first = new_project_name.project_segments.remove(0);
+ if let Some(mut definition) = projects_content.0.get_mut(&first) {
+ for segment in new_project_name.project_segments {
+ if definition.subprojects.contains_key(&segment) {
+ definition = definition
+ .subprojects
+ .get_mut(&segment)
+ .expect("We checked");
+ } else {
+ let new_definition = ProjectDefinition::default();
+ let output = definition
+ .subprojects
+ .insert(segment.clone(), new_definition);
+
+ assert_eq!(output, None);
+
+ definition = definition
+ .subprojects
+ .get_mut(&segment)
+ .expect("Was just inserted");
+ }
+ }
+ } else {
+ let mut orig_definition = ProjectDefinition::default();
+ let mut definition = &mut orig_definition;
+ for segment in new_project_name.project_segments {
+ trace!("Adding segment: {segment}");
+
+ let new_definition = ProjectDefinition::default();
+
+ assert!(definition
+ .subprojects
+ .insert(segment.clone(), new_definition)
+ .is_none());
+
+ definition = definition
+ .subprojects
+ .get_mut(&segment)
+ .expect("Was just inserted");
+ }
+ assert!(projects_content.0.insert(first, orig_definition).is_none());
+ };
+
+ let mut file = File::create(&project_file).with_context(|| {
+ format!("Failed to open project file ('{project_file:?}') for writing")
+ })?;
+ serde_json::to_writer_pretty(
+ &file,
+ &SortAlphabetically::<ProjectList>(projects_content),
+ )?;
+ writeln!(file)?;
+ }
+ }
+ Ok(())
+}
diff --git a/pkgs/by-name/ts/tskm/src/interface/project/mod.rs b/pkgs/by-name/ts/tskm/src/interface/project/mod.rs
new file mode 100644
index 00000000..62069746
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/project/mod.rs
@@ -0,0 +1,73 @@
+use std::collections::HashMap;
+
+use anyhow::Result;
+use serde::{Deserialize, Serialize};
+
+pub mod handle;
+pub use handle::handle;
+
+#[derive(Deserialize, Serialize)]
+struct ProjectList(HashMap<String, ProjectDefinition>);
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
+struct ProjectDefinition {
+ #[serde(default)]
+ #[serde(skip_serializing_if = "is_default")]
+ name: String,
+
+ #[serde(default)]
+ #[serde(skip_serializing_if = "is_default")]
+ prefix: String,
+
+ #[serde(default)]
+ #[serde(skip_serializing_if = "is_default")]
+ subprojects: HashMap<String, ProjectDefinition>,
+}
+
+fn is_default<T: Default + PartialEq>(input: &T) -> bool {
+ input == &T::default()
+}
+
+#[derive(Debug, Clone)]
+pub struct ProjectName {
+ project_segments: Vec<String>,
+}
+
+impl ProjectName {
+ #[must_use]
+ pub fn segments(&self) -> &[String] {
+ &self.project_segments
+ }
+
+ /// # Errors
+ /// Never.
+ pub fn try_from_project(s: &str) -> Result<Self> {
+ Ok(Self::from_project(s))
+ }
+ pub fn from_project(s: &str) -> Self {
+ let me = Self {
+ project_segments: s.split('.').map(ToOwned::to_owned).collect(),
+ };
+ me
+ }
+ pub fn from_context(s: &str) -> Self {
+ let me = Self {
+ project_segments: s.split('_').map(ToOwned::to_owned).collect(),
+ };
+ me
+ }
+}
+
+// Source: https://stackoverflow.com/a/67792465
+fn sort_alphabetically<T: Serialize, S: serde::Serializer>(
+ value: &T,
+ serializer: S,
+) -> Result<S::Ok, S::Error> {
+ let value = serde_json::to_value(value).map_err(serde::ser::Error::custom)?;
+ value.serialize(serializer)
+}
+
+#[derive(Serialize)]
+pub(super) struct SortAlphabetically<T: Serialize>(
+ #[serde(serialize_with = "sort_alphabetically")] T,
+);