From 2871f6d3d08a785a9fa351f8b748a41c16327964 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Fri, 2 May 2025 20:19:20 +0100
Subject: [PATCH 01/14] Port to Rust
---
.editorconfig | 210 ------
.github/workflows/build.yml | 24 +-
.github/workflows/release.yml | 40 --
.gitignore | 493 +-------------
Cargo.lock | 631 ++++++++++++++++++
Cargo.toml | 15 +
LiveTagger.sln | 31 -
README.md | 2 +-
src/AbletonMetadata.cs | 56 --
src/LiveTagger.csproj | 20 -
src/Program.cs | 163 -----
src/XmlConstants/Ableton.cs | 12 -
src/XmlConstants/Rdf.cs | 14 -
src/Xmp.cs | 272 --------
src/error.rs | 44 ++
src/lib.rs | 2 +
src/main.rs | 175 +++++
src/metadata.rs | 35 +
src/metadata/folder.rs | 443 ++++++++++++
src/metadata/sample.rs | 57 ++
.../metadata/test_data/initial.xml | 14 +-
.../metadata/test_data/tags_added.xml | 23 +-
.../metadata/test_data/tags_removed.xml | 20 +-
src/metadata/test_data/tags_removed_all.xml | 23 +
test/AbletonMetadataTests.cs | 39 --
test/LiveTaggerTests.csproj | 28 -
test/TestData/Empty.xml | 41 --
test/TestUtils.cs | 18 -
test/XmpTests.cs | 79 ---
29 files changed, 1482 insertions(+), 1542 deletions(-)
delete mode 100644 .editorconfig
delete mode 100644 .github/workflows/release.yml
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
delete mode 100644 LiveTagger.sln
delete mode 100644 src/AbletonMetadata.cs
delete mode 100644 src/LiveTagger.csproj
delete mode 100644 src/Program.cs
delete mode 100644 src/XmlConstants/Ableton.cs
delete mode 100644 src/XmlConstants/Rdf.cs
delete mode 100644 src/Xmp.cs
create mode 100644 src/error.rs
create mode 100644 src/lib.rs
create mode 100644 src/main.rs
create mode 100644 src/metadata.rs
create mode 100644 src/metadata/folder.rs
create mode 100644 src/metadata/sample.rs
rename test/TestData/WithAllTagsRemoved.xml => src/metadata/test_data/initial.xml (73%)
rename test/TestData/WithTagsAdded.xml => src/metadata/test_data/tags_added.xml (66%)
rename test/TestData/WithTagsRemoved.xml => src/metadata/test_data/tags_removed.xml (56%)
create mode 100644 src/metadata/test_data/tags_removed_all.xml
delete mode 100644 test/AbletonMetadataTests.cs
delete mode 100644 test/LiveTaggerTests.csproj
delete mode 100644 test/TestData/Empty.xml
delete mode 100644 test/TestUtils.cs
delete mode 100644 test/XmpTests.cs
diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index dd1dbe3..0000000
--- a/.editorconfig
+++ /dev/null
@@ -1,210 +0,0 @@
-# editorconfig.org
-
-# top-most EditorConfig file
-root = true
-
-# Default settings:
-# A newline ending every file
-# Use 4 spaces as indentation
-[*]
-insert_final_newline = true
-indent_style = space
-indent_size = 4
-trim_trailing_whitespace = true
-
-[project.json]
-indent_size = 2
-
-# C# and Visual Basic files
-[*.{cs,vb}]
-charset = utf-8-bom
-
-# Analyzers
-dotnet_analyzer_diagnostic.category-Security.severity = error
-dotnet_code_quality.ca1802.api_surface = private, internal
-
-# Miscellaneous style rules
-dotnet_sort_system_directives_first = true
-dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
-dotnet_style_predefined_type_for_member_access = true:suggestion
-
-# avoid this. unless absolutely necessary
-dotnet_style_qualification_for_field = false:suggestion
-dotnet_style_qualification_for_property = false:suggestion
-dotnet_style_qualification_for_method = false:suggestion
-dotnet_style_qualification_for_event = false:suggestion
-
-# name all constant fields using PascalCase
-dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
-dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
-dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style
-dotnet_naming_symbols.constant_fields.applicable_kinds = field
-dotnet_naming_symbols.constant_fields.required_modifiers = const
-dotnet_naming_style.pascal_case_style.capitalization = pascal_case
-
-# static fields should have s_ prefix
-dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion
-dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields
-dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style
-dotnet_naming_symbols.static_fields.applicable_kinds = field
-dotnet_naming_symbols.static_fields.required_modifiers = static
-dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected
-dotnet_naming_style.static_prefix_style.required_prefix = s_
-dotnet_naming_style.static_prefix_style.capitalization = camel_case
-
-# internal and private fields should be _camelCase
-dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion
-dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields
-dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style
-dotnet_naming_symbols.private_internal_fields.applicable_kinds = field
-dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal
-dotnet_naming_style.camel_case_underscore_style.required_prefix = _
-dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case
-
-# Code quality
-dotnet_style_readonly_field = true:suggestion
-dotnet_code_quality_unused_parameters = non_public:suggestion
-
-# Expression-level preferences
-dotnet_style_object_initializer = true:suggestion
-dotnet_style_collection_initializer = true:suggestion
-dotnet_style_explicit_tuple_names = true:suggestion
-dotnet_style_coalesce_expression = true:suggestion
-dotnet_style_null_propagation = true:suggestion
-dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
-dotnet_style_prefer_inferred_tuple_names = true:suggestion
-dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
-dotnet_style_prefer_auto_properties = true:suggestion
-dotnet_style_prefer_conditional_expression_over_assignment = true:refactoring
-dotnet_style_prefer_conditional_expression_over_return = true:refactoring
-
-# CA2208: Instantiate argument exceptions correctly
-dotnet_diagnostic.CA2208.severity = error
-
-# C# files
-[*.cs]
-# New line preferences
-csharp_new_line_before_open_brace = all
-csharp_new_line_before_else = true
-csharp_new_line_before_catch = true
-csharp_new_line_before_finally = true
-csharp_new_line_before_members_in_object_initializers = true
-csharp_new_line_before_members_in_anonymous_types = true
-csharp_new_line_between_query_expression_clauses = true
-
-# Indentation preferences
-csharp_indent_block_contents = true
-csharp_indent_braces = false
-csharp_indent_case_contents = true
-csharp_indent_case_contents_when_block = true
-csharp_indent_switch_labels = true
-csharp_indent_labels = one_less_than_current
-
-# Modifier preferences
-csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
-
-# Code style defaults
-csharp_using_directive_placement = outside_namespace:suggestion
-csharp_prefer_braces = true:refactoring
-csharp_preserve_single_line_blocks = true:none
-csharp_preserve_single_line_statements = false:none
-csharp_prefer_static_local_function = true:suggestion
-csharp_prefer_simple_using_statement = false:none
-csharp_style_prefer_switch_expression = true:suggestion
-
-# Expression-bodied members
-csharp_style_expression_bodied_methods = true:refactoring
-csharp_style_expression_bodied_constructors = true:refactoring
-csharp_style_expression_bodied_operators = true:refactoring
-csharp_style_expression_bodied_properties = true:refactoring
-csharp_style_expression_bodied_indexers = true:refactoring
-csharp_style_expression_bodied_accessors = true:refactoring
-csharp_style_expression_bodied_lambdas = true:refactoring
-csharp_style_expression_bodied_local_functions = true:refactoring
-
-# Pattern matching
-csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
-csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
-csharp_style_inlined_variable_declaration = true:suggestion
-
-# Expression-level preferences
-csharp_prefer_simple_default_expression = true:suggestion
-
-# Null checking preferences
-csharp_style_throw_expression = true:suggestion
-csharp_style_conditional_delegate_call = true:suggestion
-
-# Other features
-csharp_style_prefer_index_operator = false:none
-csharp_style_prefer_range_operator = false:none
-csharp_style_pattern_local_over_anonymous_function = false:none
-
-# Space preferences
-csharp_space_after_cast = false
-csharp_space_after_colon_in_inheritance_clause = true
-csharp_space_after_comma = true
-csharp_space_after_dot = false
-csharp_space_after_keywords_in_control_flow_statements = true
-csharp_space_after_semicolon_in_for_statement = true
-csharp_space_around_binary_operators = before_and_after
-csharp_space_around_declaration_statements = do_not_ignore
-csharp_space_before_colon_in_inheritance_clause = true
-csharp_space_before_comma = false
-csharp_space_before_dot = false
-csharp_space_before_open_square_brackets = false
-csharp_space_before_semicolon_in_for_statement = false
-csharp_space_between_empty_square_brackets = false
-csharp_space_between_method_call_empty_parameter_list_parentheses = false
-csharp_space_between_method_call_name_and_opening_parenthesis = false
-csharp_space_between_method_call_parameter_list_parentheses = false
-csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
-csharp_space_between_method_declaration_name_and_open_parenthesis = false
-csharp_space_between_method_declaration_parameter_list_parentheses = false
-csharp_space_between_parentheses = false
-csharp_space_between_square_brackets = false
-
-# Namespace preference
-csharp_style_namespace_declarations = file_scoped:suggestion
-
-# Types: use keywords instead of BCL types, and permit var only when the type is clear
-csharp_style_var_for_built_in_types = false:suggestion
-csharp_style_var_when_type_is_apparent = false:none
-csharp_style_var_elsewhere = false:suggestion
-
-# Visual Basic files
-[*.vb]
-# Modifier preferences
-visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion
-
-# C++ Files
-[*.{cpp,h,in}]
-curly_bracket_next_line = true
-indent_brace_style = Allman
-
-# Xml project files
-[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}]
-indent_size = 2
-
-# Xml build files
-[*.builds]
-indent_size = 2
-
-# Xml files
-[*.{xml,stylecop,resx,ruleset}]
-indent_size = 2
-
-# Xml config files
-[*.{props,targets,config,nuspec}]
-indent_size = 2
-
-# Shell scripts
-[*.sh]
-end_of_line = lf
-
-[*.{cmd, bat}]
-end_of_line = crlf
-
-# Markdown files
-[*.md]
- # Double trailing spaces can be used for BR tags, and other instances are enforced by Markdownlint
-trim_trailing_whitespace = false
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5aec640..3807dd5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -13,19 +13,23 @@ jobs:
build:
strategy:
matrix:
- os: [windows-latest, macos-latest]
+ include:
+ - build: win-x64
+ os: windows-latest
+ target: x86_64-pc-windows-msvc
+ - build: macos-x64
+ os: macos-latest
+ target: x86_64-apple-darwin
+ - build: macos-arm
+ os: macos-latest
+ target: aarch64-apple-darwin
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
+ - uses: dtolnay/rust-toolchain@master
with:
- dotnet-version: 8.0.x
- - name: Restore dependencies
- run: dotnet restore
- - name: Build
- run: dotnet build --no-restore
- - name: Test
- run: dotnet test --no-build --verbosity normal
+ toolchain: stable
+ target: ${{ matrix.target }}
+ - run: cargo test --all-features
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index a4d4ec6..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-# This workflow will build a .NET project
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
-
-name: Release
-
-on:
- release:
- types: [published]
-
-jobs:
- release:
- permissions:
- contents: write
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v4
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.0.x
- - name: Publish
- run: |
- dotnet publish src -r win-x64
- dotnet publish src -r osx-x64
- dotnet publish src -r osx-arm64
- - name: Zip artifacts
- run: |
- tag=$(git describe --tags --abbrev=0)
-
- 7z a "livetagger-$tag-win-x64.zip" "./src/bin/Release/net8.0/win-x64/publish/*"
- 7z a "livetagger-$tag-osx-x64.zip" "./src/bin/Release/net8.0/osx-x64/publish/*"
- 7z a "livetagger-$tag-osx-arm64.zip" "./src/bin/Release/net8.0/osx-arm64/publish/*"
- - name: Publish
- uses: softprops/action-gh-release@v2
- with:
- files: "livetagger*"
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 104b544..0104787 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,484 +1,17 @@
-## Ignore Visual Studio temporary files, build results, and
-## files generated by popular Visual Studio add-ons.
-##
-## Get latest from `dotnet new gitignore`
+# Generated by Cargo
+# will have compiled files and executables
+debug/
+target/
-# dotenv files
-.env
+# These are backup files generated by rustfmt
+**/*.rs.bk
-# User-specific files
-*.rsuser
-*.suo
-*.user
-*.userosscache
-*.sln.docstates
-
-# User-specific files (MonoDevelop/Xamarin Studio)
-*.userprefs
-
-# Mono auto generated files
-mono_crash.*
-
-# Build results
-[Dd]ebug/
-[Dd]ebugPublic/
-[Rr]elease/
-[Rr]eleases/
-x64/
-x86/
-[Ww][Ii][Nn]32/
-[Aa][Rr][Mm]/
-[Aa][Rr][Mm]64/
-bld/
-[Bb]in/
-[Oo]bj/
-[Ll]og/
-[Ll]ogs/
-
-# Visual Studio 2015/2017 cache/options directory
-.vs/
-# Uncomment if you have tasks that create the project's static files in wwwroot
-#wwwroot/
-
-# Visual Studio 2017 auto generated files
-Generated\ Files/
-
-# MSTest test Results
-[Tt]est[Rr]esult*/
-[Bb]uild[Ll]og.*
-
-# NUnit
-*.VisualState.xml
-TestResult.xml
-nunit-*.xml
-
-# Build Results of an ATL Project
-[Dd]ebugPS/
-[Rr]eleasePS/
-dlldata.c
-
-# Benchmark Results
-BenchmarkDotNet.Artifacts/
-
-# .NET
-project.lock.json
-project.fragment.lock.json
-artifacts/
-
-# Tye
-.tye/
-
-# ASP.NET Scaffolding
-ScaffoldingReadMe.txt
-
-# StyleCop
-StyleCopReport.xml
-
-# Files built by Visual Studio
-*_i.c
-*_p.c
-*_h.h
-*.ilk
-*.meta
-*.obj
-*.iobj
-*.pch
+# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
-*.ipdb
-*.pgc
-*.pgd
-*.rsp
-*.sbr
-*.tlb
-*.tli
-*.tlh
-*.tmp
-*.tmp_proj
-*_wpftmp.csproj
-*.log
-*.tlog
-*.vspscc
-*.vssscc
-.builds
-*.pidb
-*.svclog
-*.scc
-
-# Chutzpah Test files
-_Chutzpah*
-
-# Visual C++ cache files
-ipch/
-*.aps
-*.ncb
-*.opendb
-*.opensdf
-*.sdf
-*.cachefile
-*.VC.db
-*.VC.VC.opendb
-
-# Visual Studio profiler
-*.psess
-*.vsp
-*.vspx
-*.sap
-
-# Visual Studio Trace Files
-*.e2e
-
-# TFS 2012 Local Workspace
-$tf/
-
-# Guidance Automation Toolkit
-*.gpState
-
-# ReSharper is a .NET coding add-in
-_ReSharper*/
-*.[Rr]e[Ss]harper
-*.DotSettings.user
-
-# TeamCity is a build add-in
-_TeamCity*
-
-# DotCover is a Code Coverage Tool
-*.dotCover
-
-# AxoCover is a Code Coverage Tool
-.axoCover/*
-!.axoCover/settings.json
-
-# Coverlet is a free, cross platform Code Coverage Tool
-coverage*.json
-coverage*.xml
-coverage*.info
-
-# Visual Studio code coverage results
-*.coverage
-*.coveragexml
-
-# NCrunch
-_NCrunch_*
-.*crunch*.local.xml
-nCrunchTemp_*
-
-# MightyMoose
-*.mm.*
-AutoTest.Net/
-
-# Web workbench (sass)
-.sass-cache/
-
-# Installshield output folder
-[Ee]xpress/
-
-# DocProject is a documentation generator add-in
-DocProject/buildhelp/
-DocProject/Help/*.HxT
-DocProject/Help/*.HxC
-DocProject/Help/*.hhc
-DocProject/Help/*.hhk
-DocProject/Help/*.hhp
-DocProject/Help/Html2
-DocProject/Help/html
-
-# Click-Once directory
-publish/
-
-# Publish Web Output
-*.[Pp]ublish.xml
-*.azurePubxml
-# Note: Comment the next line if you want to checkin your web deploy settings,
-# but database connection strings (with potential passwords) will be unencrypted
-*.pubxml
-*.publishproj
-
-# Microsoft Azure Web App publish settings. Comment the next line if you want to
-# checkin your Azure Web App publish settings, but sensitive information contained
-# in these scripts will be unencrypted
-PublishScripts/
-
-# NuGet Packages
-*.nupkg
-# NuGet Symbol Packages
-*.snupkg
-# The packages folder can be ignored because of Package Restore
-**/[Pp]ackages/*
-# except build/, which is used as an MSBuild target.
-!**/[Pp]ackages/build/
-# Uncomment if necessary however generally it will be regenerated when needed
-#!**/[Pp]ackages/repositories.config
-# NuGet v3's project.json files produces more ignorable files
-*.nuget.props
-*.nuget.targets
-
-# Microsoft Azure Build Output
-csx/
-*.build.csdef
-
-# Microsoft Azure Emulator
-ecf/
-rcf/
-
-# Windows Store app package directories and files
-AppPackages/
-BundleArtifacts/
-Package.StoreAssociation.xml
-_pkginfo.txt
-*.appx
-*.appxbundle
-*.appxupload
-
-# Visual Studio cache files
-# files ending in .cache can be ignored
-*.[Cc]ache
-# but keep track of directories ending in .cache
-!?*.[Cc]ache/
-
-# Others
-ClientBin/
-~$*
-*~
-*.dbmdl
-*.dbproj.schemaview
-*.jfm
-*.pfx
-*.publishsettings
-orleans.codegen.cs
-
-# Including strong name files can present a security risk
-# (https://github.com/github/gitignore/pull/2483#issue-259490424)
-#*.snk
-
-# Since there are multiple workflows, uncomment next line to ignore bower_components
-# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
-#bower_components/
-
-# RIA/Silverlight projects
-Generated_Code/
-
-# Backup & report files from converting an old project file
-# to a newer Visual Studio version. Backup files are not needed,
-# because we have git ;-)
-_UpgradeReport_Files/
-Backup*/
-UpgradeLog*.XML
-UpgradeLog*.htm
-ServiceFabricBackup/
-*.rptproj.bak
-
-# SQL Server files
-*.mdf
-*.ldf
-*.ndf
-
-# Business Intelligence projects
-*.rdl.data
-*.bim.layout
-*.bim_*.settings
-*.rptproj.rsuser
-*- [Bb]ackup.rdl
-*- [Bb]ackup ([0-9]).rdl
-*- [Bb]ackup ([0-9][0-9]).rdl
-
-# Microsoft Fakes
-FakesAssemblies/
-
-# GhostDoc plugin setting file
-*.GhostDoc.xml
-
-# Node.js Tools for Visual Studio
-.ntvs_analysis.dat
-node_modules/
-
-# Visual Studio 6 build log
-*.plg
-
-# Visual Studio 6 workspace options file
-*.opt
-
-# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
-*.vbw
-
-# Visual Studio 6 auto-generated project file (contains which files were open etc.)
-*.vbp
-
-# Visual Studio 6 workspace and project file (working project files containing files to include in project)
-*.dsw
-*.dsp
-
-# Visual Studio 6 technical files
-*.ncb
-*.aps
-
-# Visual Studio LightSwitch build output
-**/*.HTMLClient/GeneratedArtifacts
-**/*.DesktopClient/GeneratedArtifacts
-**/*.DesktopClient/ModelManifest.xml
-**/*.Server/GeneratedArtifacts
-**/*.Server/ModelManifest.xml
-_Pvt_Extensions
-
-# Paket dependency manager
-.paket/paket.exe
-paket-files/
-
-# FAKE - F# Make
-.fake/
-
-# CodeRush personal settings
-.cr/personal
-
-# Python Tools for Visual Studio (PTVS)
-__pycache__/
-*.pyc
-
-# Cake - Uncomment if you are using it
-# tools/**
-# !tools/packages.config
-
-# Tabs Studio
-*.tss
-
-# Telerik's JustMock configuration file
-*.jmconfig
-
-# BizTalk build output
-*.btp.cs
-*.btm.cs
-*.odx.cs
-*.xsd.cs
-
-# OpenCover UI analysis results
-OpenCover/
-
-# Azure Stream Analytics local run output
-ASALocalRun/
-
-# MSBuild Binary and Structured Log
-*.binlog
-
-# NVidia Nsight GPU debugger configuration file
-*.nvuser
-
-# MFractors (Xamarin productivity tool) working folder
-.mfractor/
-
-# Local History for Visual Studio
-.localhistory/
-
-# Visual Studio History (VSHistory) files
-.vshistory/
-
-# BeatPulse healthcheck temp database
-healthchecksdb
-
-# Backup folder for Package Reference Convert tool in Visual Studio 2017
-MigrationBackup/
-
-# Ionide (cross platform F# VS Code tools) working folder
-.ionide/
-
-# Fody - auto-generated XML schema
-FodyWeavers.xsd
-
-# VS Code files for those working on multiple tools
-.vscode/*
-!.vscode/settings.json
-!.vscode/tasks.json
-!.vscode/launch.json
-!.vscode/extensions.json
-*.code-workspace
-
-# Local History for Visual Studio Code
-.history/
-
-# Windows Installer files from build outputs
-*.cab
-*.msi
-*.msix
-*.msm
-*.msp
-
-# JetBrains Rider
-*.sln.iml
-.idea
-
-##
-## Visual studio for Mac
-##
-
-
-# globs
-Makefile.in
-*.userprefs
-*.usertasks
-config.make
-config.status
-aclocal.m4
-install-sh
-autom4te.cache/
-*.tar.gz
-tarballs/
-test-results/
-
-# Mac bundle stuff
-*.dmg
-*.app
-
-# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
-# General
-.DS_Store
-.AppleDouble
-.LSOverride
-
-# Icon must end with two \r
-Icon
-
-
-# Thumbnails
-._*
-
-# Files that might appear in the root of a volume
-.DocumentRevisions-V100
-.fseventsd
-.Spotlight-V100
-.TemporaryItems
-.Trashes
-.VolumeIcon.icns
-.com.apple.timemachine.donotpresent
-
-# Directories potentially created on remote AFP share
-.AppleDB
-.AppleDesktop
-Network Trash Folder
-Temporary Items
-.apdisk
-
-# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
-# Windows thumbnail cache files
-Thumbs.db
-ehthumbs.db
-ehthumbs_vista.db
-
-# Dump file
-*.stackdump
-
-# Folder config file
-[Dd]esktop.ini
-
-# Recycle Bin used on file shares
-$RECYCLE.BIN/
-
-# Windows Installer files
-*.cab
-*.msi
-*.msix
-*.msm
-*.msp
-
-# Windows shortcuts
-*.lnk
-# Vim temporary swap files
-*.swp
+# RustRover
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..d0a37bc
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,631 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "0.6.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
+dependencies = [
+ "anstyle",
+ "once_cell",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
+
+[[package]]
+name = "bitflags"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
+
+[[package]]
+name = "cc"
+version = "1.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a"
+dependencies = [
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "4.5.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
+
+[[package]]
+name = "diff"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "getrandom"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indexmap"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
+[[package]]
+name = "jobserver"
+version = "0.1.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.172"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
+
+[[package]]
+name = "livetagger"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "glob",
+ "pretty_assertions",
+ "tracing",
+ "tracing-subscriber",
+ "xmp_toolkit",
+]
+
+[[package]]
+name = "log"
+version = "0.4.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+dependencies = [
+ "overload",
+ "winapi",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "overload"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pretty_assertions"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
+dependencies = [
+ "diff",
+ "yansi",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "smallvec"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
+
+[[package]]
+name = "toml_edit"
+version = "0.22.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10558ed0bd2a1562e630926a2d1f0b98c827da99fabd3fe20920a59642504485"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "xmp_toolkit"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2064f77c8bc1b0a1513ce64a6904d9c300c904b65cec4b237322039bab3c477"
+dependencies = [
+ "cc",
+ "fs_extra",
+ "num_enum",
+ "thiserror",
+]
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..15deec3
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "livetagger"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+anyhow = "1.0.98"
+clap = { version = "4.5.37", features = ["derive"] }
+glob = "0.3.2"
+tracing = "0.1.41"
+tracing-subscriber = "0.3.19"
+xmp_toolkit = "1.9.2"
+
+[dev-dependencies]
+pretty_assertions = "1.4.1"
diff --git a/LiveTagger.sln b/LiveTagger.sln
deleted file mode 100644
index f29e194..0000000
--- a/LiveTagger.sln
+++ /dev/null
@@ -1,31 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.5.002.0
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiveTagger", "src\LiveTagger.csproj", "{4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveTaggerTests", "test\LiveTaggerTests.csproj", "{901323E9-A7B8-419D-907A-AC96C79103F7}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Release|Any CPU.Build.0 = Release|Any CPU
- {901323E9-A7B8-419D-907A-AC96C79103F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {901323E9-A7B8-419D-907A-AC96C79103F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {901323E9-A7B8-419D-907A-AC96C79103F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {901323E9-A7B8-419D-907A-AC96C79103F7}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {7658FD62-06F3-4E35-9D2E-0CD58490507A}
- EndGlobalSection
-EndGlobal
diff --git a/README.md b/README.md
index 713e5f8..a001935 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ It allows you to quickly automate tasks that would be tedious to do via Live's U
To download LiveTagger, go to the [releases page](https://github.com/17cupsofcoffee/LiveTagger/releases) and download the .zip file for your platform. Extract this into a location that is on your system `PATH`.
-Alternatively, if you want to build from source, clone this repo and run `dotnet publish src`.
+Alternatively, if you want to build from source, clone this repo and run `cargo build`.
## Usage
diff --git a/src/AbletonMetadata.cs b/src/AbletonMetadata.cs
deleted file mode 100644
index 79ea536..0000000
--- a/src/AbletonMetadata.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-namespace LiveTagger;
-
-///
-/// Helper functions for dealing with Ableton metadata files.
-///
-public static class AbletonMetadata
-{
- ///
- /// The list of supported file extensions.
- /// See https://help.ableton.com/hc/en-us/articles/211427589-Supported-Audio-File-Formats.
- ///
- private static readonly List s_supportedExtensions = [
- ".wav",
- ".wave",
- ".aif",
- ".aiff",
- ".flac",
- ".ogg",
- ".mp3",
- ".mp4",
- ".m4a"
- ];
-
- ///
- /// Returns whether or not a path represents some kind of Ableton metadata.
- ///
- /// This is not comprehensive - it's mainly just used to exclude files from
- /// the tagging process.
- ///
- /// The path to check.
- /// Whether or not the path is for metadata.
- public static bool IsMetadata(string path)
- {
- return path.Contains("Ableton Folder Info") || Path.GetExtension(path).Equals(".asd", StringComparison.OrdinalIgnoreCase);
- }
-
- ///
- /// Returns the XMP metadata path for a given folder.
- ///
- /// The folder to find the metadata path for.
- /// The metadata path.
- public static string GetXmpFilePath(string folder)
- {
- return Path.Join(folder, "Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp");
- }
-
- ///
- /// Checks if the specified filename has a file extension that Live supports.
- ///
- /// The filename to check.
- /// Whether or not the file is supported by Live.
- public static bool IsSupportedSampleFormat(string filename)
- {
- return s_supportedExtensions.Contains(Path.GetExtension(filename).ToLower());
- }
-}
diff --git a/src/LiveTagger.csproj b/src/LiveTagger.csproj
deleted file mode 100644
index 95e65f6..0000000
--- a/src/LiveTagger.csproj
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- Exe
- net8.0
- enable
- enable
- true
- true
- true
- livetagger
- true
-
-
-
-
-
-
-
-
diff --git a/src/Program.cs b/src/Program.cs
deleted file mode 100644
index 04a50d3..0000000
--- a/src/Program.cs
+++ /dev/null
@@ -1,163 +0,0 @@
-using System.CommandLine;
-using Ganss.IO;
-
-namespace LiveTagger;
-
-class Program
-{
- static void Main(string[] args)
- {
- var rootCommand = new RootCommand("LiveTagger");
-
- var tagsArg = new Argument>("tags", "The tags to apply to the matched files.");
- var includeOption = new Option(["--include", "-i"], () => "*", "A glob pattern specifying which files should be processed.");
- var commitOption = new Option(["--commit", "-c"], "Saves changes to the filesystem. Run without this first, to make sure you're tagging the correct files!");
-
- var addTagCommand = new Command("add", "Adds tags to a set of files.");
- addTagCommand.AddArgument(tagsArg);
- addTagCommand.AddOption(includeOption);
- addTagCommand.AddOption(commitOption);
-
- addTagCommand.SetHandler(AddTags, includeOption, tagsArg, commitOption);
-
- rootCommand.AddCommand(addTagCommand);
-
- var removeTagCommand = new Command("remove", "Removes tags from a set of files.");
- removeTagCommand.AddArgument(tagsArg);
- removeTagCommand.AddOption(includeOption);
- removeTagCommand.AddOption(commitOption);
-
- removeTagCommand.SetHandler(RemoveTags, includeOption, tagsArg, commitOption);
-
- rootCommand.AddCommand(removeTagCommand);
-
- var removeAllTagsCommand = new Command("remove-all", "Removes all tags from a set of files.");
- removeAllTagsCommand.AddOption(includeOption);
- removeAllTagsCommand.AddOption(commitOption);
-
- removeAllTagsCommand.SetHandler(RemoveAllTags, includeOption, commitOption);
-
- rootCommand.AddCommand(removeAllTagsCommand);
-
- rootCommand.Invoke(args);
- }
-
- ///
- /// Adds the specified tags to all files under the given parent directory.
- ///
- /// A glob pattern specifying which files should be processed.
- /// The tags to add.
- /// Whether changes should be saved.
- private static void AddTags(string include, List tags, bool commit)
- {
- ProcessXmp(include, commit, (xmp, files) => xmp.AddTags(files, tags));
- }
-
- ///
- /// Removes the specified tags from all files under the given parent directory.
- ///
- /// A glob pattern specifying which files should be processed.
- /// The tags to remove.
- /// Whether changes should be saved.
- private static void RemoveTags(string include, List tags, bool commit)
- {
- ProcessXmp(include, commit, (xmp, files) => xmp.RemoveTags(files, tags));
- }
-
- ///
- /// Removes all tags from all files under the given parent directory.
- ///
- /// A glob pattern specifying which files should be processed.
- /// The tags to remove.
- /// Whether changes should be saved.
- private static void RemoveAllTags(string include, bool commit)
- {
- ProcessXmp(include, commit, (xmp, files) => xmp.RemoveTags(files));
- }
-
- ///
- /// Run an action on the XMP files for a given directory.
- ///
- /// A glob pattern specifying which files should be processed.
- /// Whether changes should be saved.
- /// The action to run.
- private static void ProcessXmp(string include, bool commit, Action> action)
- {
- var folders = SearchForFiles(include);
-
- foreach (var (folder, files) in folders)
- {
- Console.WriteLine($"Processing {folder}.");
-
- var xmpFilePath = AbletonMetadata.GetXmpFilePath(folder);
-
- Xmp xmp = Path.Exists(xmpFilePath)
- ? Xmp.FromFile(xmpFilePath)
- : new Xmp();
-
- action(xmp, files);
-
- if (xmp.IsDirty)
- {
- if (commit)
- {
- xmp.Save(xmpFilePath);
- }
-
- Console.WriteLine($"Metadata updated for {folder}.");
- }
- else
- {
- Console.WriteLine($"No changes required for {folder}.");
- }
- }
-
- if (commit)
- {
- Console.WriteLine("Done!");
- }
- else
- {
- Console.WriteLine("Re-run with --commit to apply the above changes.");
- }
- }
-
- ///
- /// Searches for files to process.
- ///
- /// A glob pattern specifying which files should be processed.
- /// A mapping of directories to individual files.
- private static Dictionary> SearchForFiles(string include)
- {
- var folders = new Dictionary>();
-
- foreach (string file in Glob.ExpandNames(include))
- {
- if (AbletonMetadata.IsMetadata(file) || Directory.Exists(file))
- {
- continue;
- }
-
- if (!AbletonMetadata.IsSupportedSampleFormat(file))
- {
- Console.WriteLine($"Skipping {file} as it doesn't look like an audio file");
- continue;
- }
-
- var parent = Directory.GetParent(file)!.FullName;
- var filename = Path.GetFileName(file);
-
- if (folders.ContainsKey(parent))
- {
- folders[parent].Add(filename);
- }
- else
- {
- folders.Add(parent, [filename]);
- }
- }
-
- return folders;
- }
-
-}
diff --git a/src/XmlConstants/Ableton.cs b/src/XmlConstants/Ableton.cs
deleted file mode 100644
index aa68942..0000000
--- a/src/XmlConstants/Ableton.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System.Xml.Linq;
-
-namespace LiveTagger.XmlConstants;
-
-public static class Ableton
-{
- public static readonly XNamespace Namespace = "https://ns.ableton.com/xmp/fs-resources/1.0/";
-
- public static readonly XName Items = Namespace + "items";
- public static readonly XName FilePath = Namespace + "filePath";
- public static readonly XName Keywords = Namespace + "keywords";
-}
diff --git a/src/XmlConstants/Rdf.cs b/src/XmlConstants/Rdf.cs
deleted file mode 100644
index 8f51ca7..0000000
--- a/src/XmlConstants/Rdf.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System.Xml.Linq;
-
-namespace LiveTagger.XmlConstants;
-
-public static class Rdf
-{
- public static readonly XNamespace Namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
-
- public static readonly XName RdfWrapper = Namespace + "RDF";
- public static readonly XName Description = Namespace + "Description";
- public static readonly XName Bag = Namespace + "Bag";
- public static readonly XName Li = Namespace + "li";
- public static readonly XName ParseType = Namespace + "parseType";
-}
\ No newline at end of file
diff --git a/src/Xmp.cs b/src/Xmp.cs
deleted file mode 100644
index ce9764e..0000000
--- a/src/Xmp.cs
+++ /dev/null
@@ -1,272 +0,0 @@
-using System.Xml.Linq;
-using LiveTagger.XmlConstants;
-
-namespace LiveTagger;
-
-public class Xmp
-{
- ///
- /// Base template for new XMP files.
- ///
- private static readonly string s_xmlTemplate = """
-
-
-
-
- application/vnd.ableton.folder
- folder
-
-
-
-
-
-
- """;
-
- ///
- /// Whether any changes have been made to the document since the last save.
- ///
- public bool IsDirty { get; private set; } = false;
-
- ///
- /// The in-memory XML document.
- ///
- public XElement Xml { get; private set; }
-
- ///
- /// A pointer to the main content of the document.
- ///
- private XElement _itemsBag;
-
- ///
- /// Creates a new XMP document.
- ///
- /// The XML to populate the document with.
- private Xmp(XElement xml)
- {
- Xml = xml;
-
- _itemsBag = Xml.Element(Rdf.RdfWrapper)?
- .Element(Rdf.Description)?
- .Element(Ableton.Items)?
- .Element(Rdf.Bag)
- ?? throw new InvalidOperationException("Malformed XMP document");
- }
-
- ///
- /// Creates a new XMP document, with no metadata.
- ///
- public Xmp() : this(XElement.Parse(s_xmlTemplate.Trim())) { }
-
- ///
- /// Loads an XMP document from a string.
- ///
- /// The XML to load.
- /// The loaded XMP document.
- public static Xmp FromString(string xml)
- {
- return new Xmp(XElement.Parse(xml));
- }
-
- ///
- /// Loads an XMP document from the filesystem.
- ///
- /// The path to the XMP file to load.
- /// The loaded XMP document.
- public static Xmp FromFile(string path)
- {
- return new Xmp(XElement.Load(path));
- }
-
- ///
- /// Saves the XMP document to the filesystem, including any changes.
- /// If the file hasn't actually been changed, this will have no effect.
- /// If the file already exists, a backup will be made.
- ///
- /// The path to save the file to.
- public void Save(string path)
- {
- if (Path.Exists(path))
- {
- File.Copy(path, path + ".bak", true);
- }
- else
- {
- Directory.CreateDirectory(Path.GetDirectoryName(path));
- }
-
- Xml.Save(path);
-
- IsDirty = false;
- }
-
- ///
- /// Tag a file.
- ///
- /// The file to tag.
- /// The tags to apply.
- public void AddTags(string file, List tags)
- {
- var tagsAdded = new List();
-
- var existingItem = GetFileItem(file);
-
- if (existingItem != null)
- {
- // Add keyword to existing items
- var keywords = existingItem.Element(Ableton.Keywords);
- var keywordBag = keywords.Element(Rdf.Bag);
-
- foreach (string tag in tags)
- {
- if (!keywordBag.Elements(Rdf.Li).Any(e => e.Value == tag))
- {
- keywordBag.Add(new XElement(Rdf.Li, tag));
- tagsAdded.Add(tag);
- IsDirty = true;
- }
- }
- }
- else
- {
- // Create new item
- var newItem = new XElement(Rdf.Li, new XAttribute(Rdf.ParseType, "Resource"),
- new XElement(Ableton.FilePath, file),
- new XElement(Ableton.Keywords,
- new XElement(Rdf.Bag,
- tags.Select(tag => new XElement(Rdf.Li, tag))
- )
- )
- );
-
- _itemsBag.Add(newItem);
- tagsAdded = tags;
- IsDirty = true;
- }
-
- if (tagsAdded.Count > 0)
- {
- Console.WriteLine($"Added tags to {file}: {string.Join(", ", tagsAdded)}");
- }
- }
-
-
- ///
- /// Tag a list of files.
- ///
- /// The files to tag.
- /// The tags to apply.
- public void AddTags(List files, List tags)
- {
- foreach (string file in files)
- {
- AddTags(file, tags);
- }
- }
-
- ///
- /// Remove a specific set of tags from a file.
- ///
- /// The file to untag.
- /// The tags to remove.
- public void RemoveTags(string file, List tags)
- {
- var item = GetFileItem(file);
-
- if (item != null)
- {
- var keywords = item.Element(Ableton.Keywords);
- var keywordBag = keywords.Element(Rdf.Bag);
-
- var tagsRemoved = new List();
- var elementsToRemove = new List();
-
- foreach (XElement keyword in keywordBag.Elements(Rdf.Li))
- {
- if (tags.Contains(keyword.Value))
- {
- tagsRemoved.Add(keyword.Value);
- elementsToRemove.Add(keyword);
- }
- }
-
- if (elementsToRemove.Count > 0)
- {
- if (elementsToRemove.Count == keywordBag.Elements(Rdf.Li).Count())
- {
- keywords.Remove();
- }
- else
- {
- elementsToRemove.Remove();
- }
-
- IsDirty = true;
- Console.WriteLine($"Removed tags from {file}: {string.Join(", ", tagsRemoved)}");
- }
- }
- }
-
- ///
- /// Remove a specific set of tags from a list of files.
- ///
- /// The files to untag.
- /// The tags to remove.
- public void RemoveTags(List files, List tags)
- {
- foreach (string file in files)
- {
- RemoveTags(file, tags);
- }
- }
-
- ///
- /// Remove all tags from a file.
- ///
- /// The file to untag.
- public void RemoveTags(string file)
- {
- var item = GetFileItem(file);
-
- if (item != null)
- {
- var keywords = item.Element(Ableton.Keywords);
-
- keywords.Remove();
- IsDirty = true;
- Console.WriteLine($"Removed all tags from {file}");
- }
- }
-
- ///
- /// Remove all tags from a list of files.
- ///
- /// The files to untag.
- public void RemoveTags(List files)
- {
- foreach (string file in files)
- {
- RemoveTags(file);
- }
- }
-
- ///
- /// Gets the bag item for the given filename.
- ///
- ///
- ///
- private XElement? GetFileItem(string file)
- {
- foreach (var item in _itemsBag.Elements(Rdf.Li))
- {
- var filePath = item.Element(Ableton.FilePath);
-
- if (filePath != null && filePath.Value == file)
- {
- return item;
- }
- }
-
- return null;
- }
-}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..ace0fae
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,44 @@
+use std::fmt::{self, Display};
+
+use xmp_toolkit::XmpError;
+
+#[derive(Debug)]
+pub enum Error {
+ Io(std::io::Error),
+ Xmp(XmpError),
+ MissingField(&'static str),
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Error::Io(e) => Some(e),
+ Error::Xmp(e) => Some(e),
+ Error::MissingField(_) => None,
+ }
+ }
+}
+
+impl Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::Io(_) => write!(f, "io error"),
+ Error::Xmp(_) => write!(f, "xmp error"),
+ Error::MissingField(field) => write!(f, "missing field: {}", field),
+ }
+ }
+}
+
+impl From for Error {
+ fn from(value: std::io::Error) -> Self {
+ Error::Io(value)
+ }
+}
+
+impl From for Error {
+ fn from(value: XmpError) -> Self {
+ Error::Xmp(value)
+ }
+}
+
+pub type Result = std::result::Result;
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..58d8d18
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod error;
+pub mod metadata;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..ae746d5
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,175 @@
+use std::collections::{HashMap, HashSet};
+use std::ffi::OsStr;
+
+use std::fs;
+use std::path::PathBuf;
+
+use anyhow::Context;
+use clap::{Args, Parser, Subcommand};
+use glob::glob;
+
+use livetagger::metadata::{self, FolderMetadata};
+use tracing::info;
+
+#[derive(Parser, Debug)]
+#[command(version, about, long_about = None)]
+#[command(propagate_version = true)]
+struct Cli {
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Subcommand, Debug)]
+enum Command {
+ /// Adds tags to a set of files.
+ Add(CommandArgs),
+
+ /// Removes tags from a set of files.
+ Remove(CommandArgs),
+
+ /// Removes all tags from a set of files.
+ RemoveAll(CommandArgs),
+}
+
+#[derive(Args, Debug)]
+struct CommandArgs {
+ /// The tags to apply to the matched files.
+ #[arg(required(true))]
+ tags: Vec,
+
+ /// A glob pattern specifying which files should be processed.
+ #[arg(short, long, value_name = "GLOB", default_value = "*")]
+ include: String,
+
+ /// Saves changes to the filesystem. Run without this first, to make sure you're tagging the correct files!
+ #[arg(short, long)]
+ commit: bool,
+
+ /// Creates backups of any changed metadata.
+ #[arg(short, long)]
+ backup: bool,
+}
+
+fn main() -> anyhow::Result<()> {
+ let cli = Cli::parse();
+
+ tracing_subscriber::fmt().with_target(false).init();
+
+ match cli.command {
+ Command::Add(args) => {
+ process_xmp(&args.include, args.commit, args.backup, |xmp, files| {
+ metadata::add_tags(xmp, files, &args.tags)?;
+
+ Ok(())
+ })?;
+ }
+
+ Command::Remove(args) => {
+ process_xmp(&args.include, args.commit, args.backup, |xmp, files| {
+ metadata::remove_tags(xmp, files, &args.tags)?;
+
+ Ok(())
+ })?;
+ }
+
+ Command::RemoveAll(args) => {
+ process_xmp(&args.include, args.commit, args.backup, |xmp, files| {
+ metadata::remove_all_tags(xmp, files)?;
+
+ Ok(())
+ })?;
+ }
+ }
+
+ Ok(())
+}
+
+fn process_xmp(include: &str, commit: bool, backup: bool, mut action: F) -> anyhow::Result<()>
+where
+ F: FnMut(&mut FolderMetadata, HashSet) -> anyhow::Result<()>,
+{
+ let folders = search_for_files(include)?;
+
+ for (folder, files) in folders {
+ info!("Processing {}", folder.display());
+
+ let xmp_path = metadata::get_folder_metadata_path(&folder);
+
+ let (mut xmp, new_file) = if xmp_path.exists() {
+ (FolderMetadata::from_xmp_file(&xmp_path)?, false)
+ } else {
+ (FolderMetadata::new()?, true)
+ };
+
+ action(&mut xmp, files)?;
+
+ if xmp.is_dirty() {
+ xmp.set_creator_tool("Updated by LiveTagger")?;
+
+ if new_file {
+ xmp.update_create_date()?;
+ } else {
+ xmp.update_metadata_date()?;
+ }
+
+ if commit {
+ if backup {
+ let backup_path = xmp_path.with_extension("xmp.bak");
+
+ fs::rename(&xmp_path, &backup_path)?;
+ info!("Backup written to {}", backup_path.display())
+ }
+
+ fs::write(&xmp_path, &xmp.to_xml()?)?;
+
+ info!("Metadata updated for {}", folder.display())
+ }
+ } else {
+ info!("No changes required for {}", folder.display());
+ }
+ }
+
+ Ok(())
+}
+
+fn search_for_files(include: &str) -> anyhow::Result>> {
+ let mut folders: HashMap> = HashMap::new();
+
+ for entry in glob(include).context("Invalid include glob")? {
+ let path = entry.context("Invalid path")?;
+
+ if metadata::is_metadata(&path) || path.is_dir() {
+ continue;
+ }
+
+ if !metadata::is_supported_sample_format(&path) {
+ info!(
+ "Skipping {} as it doesn't look like an audio file",
+ path.display()
+ );
+
+ continue;
+ }
+
+ let (Some(parent), Some(filename)) =
+ (path.parent(), path.file_name().and_then(OsStr::to_str))
+ else {
+ continue;
+ };
+
+ match folders.get_mut(parent) {
+ Some(folder) => {
+ folder.insert(filename.to_string());
+ }
+
+ None => {
+ let mut set = HashSet::new();
+ set.insert(filename.to_string());
+
+ folders.insert(parent.to_path_buf(), set);
+ }
+ }
+ }
+
+ Ok(folders)
+}
diff --git a/src/metadata.rs b/src/metadata.rs
new file mode 100644
index 0000000..e02bd3f
--- /dev/null
+++ b/src/metadata.rs
@@ -0,0 +1,35 @@
+mod folder;
+mod sample;
+
+pub use folder::*;
+pub use sample::*;
+
+use std::path::Path;
+
+/// Returns whether or not a path points at Ableton Live metadata.
+pub fn is_metadata(path: &Path) -> bool {
+ is_sample_metadata(path) || is_folder_metadata(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_detect_folder_metadata() {
+ assert!(is_metadata(Path::new(
+ "C:/foo/Ableton Folder Info/file.txt"
+ )));
+ }
+
+ #[test]
+ fn should_detect_sample_metadata() {
+ assert!(is_metadata(Path::new("C:/foo/metadata.asd")));
+ assert!(is_metadata(Path::new("C:/foo/metadata.ASD")));
+ }
+
+ #[test]
+ fn should_ignore_non_metadata_file() {
+ assert!(!is_metadata(Path::new("C:/foo/sound.wav")));
+ }
+}
diff --git a/src/metadata/folder.rs b/src/metadata/folder.rs
new file mode 100644
index 0000000..14b96bb
--- /dev/null
+++ b/src/metadata/folder.rs
@@ -0,0 +1,443 @@
+use std::collections::HashSet;
+use std::ffi::OsStr;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use tracing::info;
+use xmp_toolkit::{FromStrOptions, ToStringOptions, XmpDateTime, XmpMeta, XmpValue, xmp_ns};
+
+use crate::error::{Error, Result};
+
+const ABLETON_NS: &str = "https://ns.ableton.com/xmp/fs-resources/1.0/";
+
+static METADATA_TEMPLATE: &str = r#"
+
+
+
+ application/vnd.ableton.folder
+ folder
+
+
+
+
+
+"#;
+
+/// Returns the path to a given folder's Ableton Live metadata.
+pub fn get_folder_metadata_path(folder: &Path) -> PathBuf {
+ folder.join("Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp")
+}
+
+/// Returns whether or not a path points at Ableton Live folder metadata.
+///
+/// This includes the 'Ableton Folder Info' subdirectory itself, as well as
+/// any files contained within it.
+pub fn is_folder_metadata(path: &Path) -> bool {
+ path.iter()
+ .filter_map(OsStr::to_str)
+ .any(|component| component == "Ableton Folder Info")
+}
+
+/// Adds tags to the specified files.
+///
+/// If an entry for a file does not exist yet in the metadata document, it will be added.
+pub fn add_tags(doc: &mut FolderMetadata, mut files: HashSet, tags: &[String]) -> Result {
+ let item_count = doc.item_count();
+
+ for i in 1..=item_count {
+ let mut tags_added = Vec::new();
+
+ let item = ItemSelector::new(i)?;
+
+ let filename = doc.get_filename(&item)?;
+
+ if files.take(&filename).is_some() {
+ let keyword_count = doc.keyword_count(&item);
+ let mut keywords = HashSet::new();
+
+ for i in 1..=keyword_count {
+ keywords.insert(doc.get_keyword(&item, i)?);
+ }
+
+ for tag in tags {
+ if !keywords.contains(tag) {
+ doc.push_keyword(&item, tag.clone())?;
+ tags_added.push(tag.as_str());
+ }
+ }
+
+ if !tags_added.is_empty() {
+ info!("Added tags to {}: {}", filename, tags_added.join(", "));
+ }
+ }
+ }
+
+ for (i, new_file) in files.into_iter().enumerate() {
+ let mut tags_added = Vec::new();
+
+ let item = ItemSelector::new(item_count + i + 1)?;
+
+ doc.set_filename(&item, new_file.clone())?;
+
+ for tag in tags {
+ doc.push_keyword(&item, tag.clone())?;
+ tags_added.push(tag.as_str());
+ }
+
+ if !tags_added.is_empty() {
+ info!("Added tags to {}: {}", &new_file, tags_added.join(", "));
+ }
+ }
+
+ Ok(())
+}
+
+/// Removes tags from the specified files.
+///
+/// This will not remove the files themselves from the metadata document, even
+/// if all the keywords are gone - while keywords are currently the only
+/// metadata stored for each file, Ableton could potentially add
+/// additional data in future versions.
+pub fn remove_tags(
+ doc: &mut FolderMetadata,
+ mut files: HashSet,
+ tags: &[String],
+) -> Result {
+ let item_count = doc.item_count();
+
+ for i in 1..=item_count {
+ let mut tags_removed = Vec::new();
+
+ let item = ItemSelector::new(i)?;
+
+ let filename = doc.get_filename(&item)?;
+
+ if files.take(&filename).is_some() {
+ let keyword_count = doc.keyword_count(&item);
+
+ let mut deleted_count = 0;
+
+ // We iterate in reverse to avoid invalidating the indices
+ // when elements get deleted.
+ for i in (1..=keyword_count).rev() {
+ let keyword = doc.get_keyword(&item, i)?;
+
+ if tags.contains(&keyword) {
+ doc.delete_keyword(&item, i)?;
+ tags_removed.push(keyword);
+
+ deleted_count += 1;
+ }
+ }
+
+ if deleted_count == keyword_count {
+ doc.delete_keywords(&item)?;
+ }
+
+ if !tags_removed.is_empty() {
+ info!(
+ "Removed tags from {}: {}",
+ &filename,
+ tags_removed.join(", ")
+ );
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Removed all tags from the specified files.
+///
+/// This will not remove the files themselves from the metadata document -
+/// while keywords are currently the only metadata stored for each file,
+/// Ableton could potentially add additional data in future versions.
+pub fn remove_all_tags(doc: &mut FolderMetadata, mut files: HashSet) -> Result {
+ let item_count = doc.item_count();
+
+ for i in 1..=item_count {
+ let item = ItemSelector::new(i)?;
+
+ let filename = doc.get_filename(&item)?;
+
+ if files.take(&filename).is_some() {
+ doc.delete_keywords(&item)?;
+
+ info!("Removed all tags from {}", &filename);
+ }
+ }
+
+ Ok(())
+}
+
+/// Ableton Live metadata for a folder.
+#[derive(Debug)]
+pub struct FolderMetadata {
+ xmp: XmpMeta,
+ dirty: bool,
+}
+
+impl FolderMetadata {
+ /// Create an empty document.
+ pub fn new() -> Result {
+ Self::from_xmp_str(METADATA_TEMPLATE)
+ }
+
+ /// Reads a document from a `&str`.
+ pub fn from_xmp_str(data: &str) -> Result {
+ Ok(FolderMetadata {
+ xmp: XmpMeta::from_str_with_options(data, FromStrOptions::default())?,
+ dirty: false,
+ })
+ }
+
+ /// Reads a document from a `.xmp` file.
+ pub fn from_xmp_file(path: &Path) -> Result {
+ let data = fs::read_to_string(path)?;
+
+ Self::from_xmp_str(&data)
+ }
+
+ /// Returns whether the document has changed since it was loaded.
+ pub fn is_dirty(&self) -> bool {
+ self.dirty
+ }
+
+ /// Outputs the document as XML.
+ pub fn to_xml(&self) -> Result {
+ let xml = self.xmp.to_string_with_options(
+ ToStringOptions::default()
+ .omit_packet_wrapper()
+ .set_indent_string(" ".into()),
+ )?;
+
+ Ok(xml)
+ }
+
+ /// Sets the 'CreatorTool' property on the document.
+ pub fn set_creator_tool(&mut self, value: impl Into) -> Result {
+ self.xmp
+ .set_property(xmp_ns::XMP, "CreatorTool", &XmpValue::new(value.into()))?;
+
+ self.dirty = true;
+
+ Ok(())
+ }
+
+ /// Sets the 'CreateDate' property on the document to the current date and time.
+ pub fn update_create_date(&mut self) -> Result {
+ self.xmp.set_property_date(
+ xmp_ns::XMP,
+ "CreateDate",
+ &XmpValue::new(XmpDateTime::current()?),
+ )?;
+
+ self.dirty = true;
+
+ Ok(())
+ }
+
+ /// Sets the 'MetadataDate' property on the document to the current date and time.
+ pub fn update_metadata_date(&mut self) -> Result {
+ self.xmp.set_property_date(
+ xmp_ns::XMP,
+ "MetadataDate",
+ &XmpValue::new(XmpDateTime::current()?),
+ )?;
+
+ self.dirty = true;
+
+ Ok(())
+ }
+
+ /// Returns the number of items (aka tagged files) in the document.
+ pub fn item_count(&self) -> usize {
+ self.xmp.array_len(ABLETON_NS, "items")
+ }
+
+ /// Reads the filename of an item in the document.
+ pub fn get_filename(&self, item: &ItemSelector) -> Result {
+ self.xmp
+ .property(ABLETON_NS, &item.filename)
+ .map(|v| v.value)
+ .ok_or(Error::MissingField(""))
+ }
+
+ /// Sets the filename of an item in the document.
+ pub fn set_filename(&mut self, item: &ItemSelector, value: impl Into) -> Result {
+ self.xmp
+ .set_property(ABLETON_NS, &item.filename, &XmpValue::new(value.into()))?;
+
+ self.dirty = true;
+
+ Ok(())
+ }
+
+ /// Returns the number of keywords (aka tags) for an item in the document.
+ pub fn keyword_count(&self, item: &ItemSelector) -> usize {
+ self.xmp.array_len(ABLETON_NS, &item.keywords.value)
+ }
+
+ /// Reads a keyword from an item in the document.
+ pub fn get_keyword(&self, item: &ItemSelector, i: usize) -> Result {
+ self.xmp
+ .array_item(ABLETON_NS, &item.keywords.value, i as i32)
+ .map(|v| v.value)
+ .ok_or(Error::MissingField(""))
+ }
+
+ /// Adds a keyword to an item in the document.
+ pub fn push_keyword(&mut self, item: &ItemSelector, value: impl Into) -> Result {
+ self.xmp
+ .append_array_item(ABLETON_NS, &item.keywords, &XmpValue::new(value.into()))?;
+
+ self.dirty = true;
+
+ Ok(())
+ }
+
+ /// Deletes a keyword from an item in the document.
+ ///
+ /// This will not remove the item itself, even if all the keywords are gone -
+ /// while keywords are currently the only metadata stored for each file,
+ /// Ableton could potentially add additional data in future versions.
+ pub fn delete_keyword(&mut self, item: &ItemSelector, i: usize) -> Result {
+ self.xmp
+ .delete_array_item(ABLETON_NS, &item.keywords.value, i as i32)?;
+
+ self.dirty = true;
+
+ Ok(())
+ }
+
+ /// Deletes all keywords from an item in the document.
+ ///
+ /// This will not remove the item itself - while keywords are currently the
+ /// only metadata stored for each file, Ableton could potentially add
+ /// additional data in future versions.
+ pub fn delete_keywords(&mut self, item: &ItemSelector) -> Result {
+ self.xmp.delete_property(ABLETON_NS, &item.keywords.value)?;
+
+ self.dirty = true;
+
+ Ok(())
+ }
+}
+
+/// Paths for an individual item in a metadata document.
+pub struct ItemSelector {
+ filename: String,
+ keywords: XmpValue,
+}
+
+impl ItemSelector {
+ /// Creates a new item selector for an item in a documents.
+ ///
+ /// This does not validate that the item actually exists!
+ pub fn new(i: usize) -> Result {
+ let item_path = XmpMeta::compose_array_item_path(ABLETON_NS, "items", i as i32)?;
+
+ let filename =
+ XmpMeta::compose_struct_field_path(ABLETON_NS, &item_path, ABLETON_NS, "filePath")?;
+
+ let keywords =
+ XmpMeta::compose_struct_field_path(ABLETON_NS, &item_path, ABLETON_NS, "keywords")?;
+
+ Ok(ItemSelector {
+ filename,
+ keywords: XmpValue::new(keywords).set_is_array(true),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_detect_folder_metadata() {
+ assert!(is_folder_metadata(Path::new(
+ "C:/foo/Ableton Folder Info/file.txt"
+ )));
+ }
+
+ #[test]
+ fn should_ignore_non_metadata_file() {
+ assert!(!is_folder_metadata(Path::new("C:/foo/sound.wav")));
+ }
+
+ #[test]
+ fn should_get_folder_metadata_path() {
+ assert_eq!(
+ get_folder_metadata_path(Path::new("C:/foo")),
+ PathBuf::from("C:/foo/Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp")
+ );
+ }
+
+ #[test]
+ fn should_add_tags() -> Result {
+ let initial = include_str!("test_data/initial.xml");
+ let expected = include_str!("test_data/tags_added.xml");
+
+ let mut meta = FolderMetadata::from_xmp_str(initial)?;
+
+ let mut files = HashSet::new();
+
+ files.insert("bd1.wav".into());
+ files.insert("bd2.wav".into());
+ files.insert("bd3.wav".into());
+
+ let tags = ["Drums|Kick".into(), "CustomTag".into()];
+
+ add_tags(&mut meta, files, &tags)?;
+
+ assert!(meta.is_dirty());
+ pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
+
+ Ok(())
+ }
+
+ #[test]
+ fn should_remove_tags() -> Result {
+ let initial = include_str!("test_data/initial.xml");
+ let expected = include_str!("test_data/tags_removed.xml");
+
+ let mut meta = FolderMetadata::from_xmp_str(initial)?;
+
+ let mut files = HashSet::new();
+
+ files.insert("bd1.wav".into());
+ files.insert("bd2.wav".into());
+ files.insert("bd3.wav".into());
+
+ let tags = ["Creator|17cupsofcoffee".into(), "NonExistentTag".into()];
+
+ remove_tags(&mut meta, files, &tags)?;
+
+ assert!(meta.is_dirty());
+ pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
+
+ Ok(())
+ }
+
+ #[test]
+ fn should_remove_all_tags() -> Result {
+ let initial = include_str!("test_data/initial.xml");
+ let expected = include_str!("test_data/tags_removed_all.xml");
+
+ let mut meta = FolderMetadata::from_xmp_str(initial)?;
+
+ let mut files = HashSet::new();
+
+ files.insert("bd1.wav".into());
+ files.insert("bd2.wav".into());
+ files.insert("bd3.wav".into());
+
+ remove_all_tags(&mut meta, files)?;
+
+ assert!(meta.is_dirty());
+ pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
+
+ Ok(())
+ }
+}
diff --git a/src/metadata/sample.rs b/src/metadata/sample.rs
new file mode 100644
index 0000000..cec4ccc
--- /dev/null
+++ b/src/metadata/sample.rs
@@ -0,0 +1,57 @@
+use std::ffi::OsStr;
+use std::path::Path;
+
+static SUPPORTED_EXTS: &[&str] = &[
+ "wav", "wave", "aif", "aiff", "flac", "ogg", "mp3", "mp4", "m4a",
+];
+
+/// Returns whether or not a path points at an Ableton Live sample analysis file.
+pub fn is_sample_metadata(path: &Path) -> bool {
+ path.extension()
+ .and_then(OsStr::to_str)
+ .filter(|ext| ext.eq_ignore_ascii_case("asd"))
+ .is_some()
+}
+
+/// Returns whether a path's extension matches any of Ableton Live's supported
+/// sample formats.
+///
+/// See .
+pub fn is_supported_sample_format(path: &Path) -> bool {
+ let Some(ext) = path.extension().and_then(OsStr::to_str) else {
+ return false;
+ };
+
+ SUPPORTED_EXTS
+ .iter()
+ .any(|supported| supported.eq_ignore_ascii_case(ext))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_detect_sample_files() {
+ let samples = [
+ "sound.wav",
+ "sound.wave",
+ "sound.aif",
+ "sound.aiff",
+ "sound.flac",
+ "sound.ogg",
+ "sound.mp3",
+ "sound.mp4",
+ "sound.m4a",
+ ];
+
+ for sample in samples {
+ assert!(is_supported_sample_format(Path::new(sample)));
+ assert!(is_supported_sample_format(Path::new(
+ &sample.to_uppercase()
+ )));
+ }
+
+ assert!(!is_supported_sample_format(Path::new("sound.exe")));
+ }
+}
diff --git a/test/TestData/WithAllTagsRemoved.xml b/src/metadata/test_data/initial.xml
similarity index 73%
rename from test/TestData/WithAllTagsRemoved.xml
rename to src/metadata/test_data/initial.xml
index c5b0547..5a91278 100644
--- a/test/TestData/WithAllTagsRemoved.xml
+++ b/src/metadata/test_data/initial.xml
@@ -1,7 +1,9 @@
-
-
+
application/vnd.ableton.folder
folder
@@ -19,16 +21,14 @@
bd2.wav
- Drums|Kick
Creator|17cupsofcoffee
-
- ch.wav
-
+ Ableton Index 12.1
+ 2024-10-10T20:42:58+01:00
-
\ No newline at end of file
+
diff --git a/test/TestData/WithTagsAdded.xml b/src/metadata/test_data/tags_added.xml
similarity index 66%
rename from test/TestData/WithTagsAdded.xml
rename to src/metadata/test_data/tags_added.xml
index 0175e80..e48c672 100644
--- a/test/TestData/WithTagsAdded.xml
+++ b/src/metadata/test_data/tags_added.xml
@@ -1,7 +1,9 @@
-
-
+
-
+
application/vnd.ableton.folder
folder
@@ -12,6 +14,7 @@
Drums|Kick
Creator|17cupsofcoffee
+ CustomTag
@@ -19,23 +22,25 @@
bd2.wav
- Drums|Kick
Creator|17cupsofcoffee
+ Drums|Kick
+ CustomTag
- ch.wav
+ bd3.wav
- Drums|Hihat
- Drums|Hihat|Closed Hihat
- Creator|17cupsofcoffee
+ Drums|Kick
+ CustomTag
+ Ableton Index 12.1
+ 2024-10-10T20:42:58+01:00
-
\ No newline at end of file
+
diff --git a/test/TestData/WithTagsRemoved.xml b/src/metadata/test_data/tags_removed.xml
similarity index 56%
rename from test/TestData/WithTagsRemoved.xml
rename to src/metadata/test_data/tags_removed.xml
index d2a250f..eedb18c 100644
--- a/test/TestData/WithTagsRemoved.xml
+++ b/src/metadata/test_data/tags_removed.xml
@@ -1,7 +1,9 @@
-
-
+
-
+
application/vnd.ableton.folder
folder
@@ -16,17 +18,11 @@
bd2.wav
-
-
- Drums|Kick
-
-
-
-
- ch.wav
+ Ableton Index 12.1
+ 2024-10-10T20:42:58+01:00
-
\ No newline at end of file
+
diff --git a/src/metadata/test_data/tags_removed_all.xml b/src/metadata/test_data/tags_removed_all.xml
new file mode 100644
index 0000000..9b19186
--- /dev/null
+++ b/src/metadata/test_data/tags_removed_all.xml
@@ -0,0 +1,23 @@
+
+
+
+ application/vnd.ableton.folder
+ folder
+
+
+
+ bd1.wav
+
+
+ bd2.wav
+
+
+
+ Ableton Index 12.1
+ 2024-10-10T20:42:58+01:00
+
+
+
diff --git a/test/AbletonMetadataTests.cs b/test/AbletonMetadataTests.cs
deleted file mode 100644
index bf4c500..0000000
--- a/test/AbletonMetadataTests.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-namespace LiveTagger.Tests;
-
-public class AbletonMetadataTests
-{
- [Theory]
- [InlineData("C:/foo/Ableton Folder Info/file.txt", true)]
- [InlineData("C:/foo/metadata.asd", true)]
- [InlineData("C:/foo/metadata.ASD", true)]
- [InlineData("C:/foo/sound.wav", false)]
- public void ShouldDetectMetadata(string path, bool isValid)
- {
- Assert.Equal(isValid, AbletonMetadata.IsMetadata(path));
- }
-
- [Fact]
- public void ShouldReturnXmpFilePath()
- {
- var xmpPath = AbletonMetadata.GetXmpFilePath("C:/foo");
-
- Assert.Equivalent(new FileInfo("C:/foo/Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp"), new FileInfo(xmpPath));
- }
-
- [Theory]
- [InlineData("sound.wav", true)]
- [InlineData("sound.wave", true)]
- [InlineData("sound.aif", true)]
- [InlineData("sound.aiff", true)]
- [InlineData("sound.flac", true)]
- [InlineData("sound.ogg", true)]
- [InlineData("sound.mp3", true)]
- [InlineData("sound.mp4", true)]
- [InlineData("sound.m4a", true)]
- [InlineData("sound.WAV", true)]
- [InlineData("sound.exe", false)]
- public void ShouldDetectSampleFiles(string filename, bool isValid)
- {
- Assert.Equal(isValid, AbletonMetadata.IsSupportedSampleFormat(filename));
- }
-}
diff --git a/test/LiveTaggerTests.csproj b/test/LiveTaggerTests.csproj
deleted file mode 100644
index f4f3d08..0000000
--- a/test/LiveTaggerTests.csproj
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
- false
- true
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/test/TestData/Empty.xml b/test/TestData/Empty.xml
deleted file mode 100644
index 0175e80..0000000
--- a/test/TestData/Empty.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
- application/vnd.ableton.folder
- folder
-
-
-
- bd1.wav
-
-
- Drums|Kick
- Creator|17cupsofcoffee
-
-
-
-
- bd2.wav
-
-
- Drums|Kick
- Creator|17cupsofcoffee
-
-
-
-
- ch.wav
-
-
- Drums|Hihat
- Drums|Hihat|Closed Hihat
- Creator|17cupsofcoffee
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test/TestUtils.cs b/test/TestUtils.cs
deleted file mode 100644
index f5265de..0000000
--- a/test/TestUtils.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Runtime.CompilerServices;
-
-namespace LiveTagger.Tests;
-
-///
-/// Utilities for testing.
-///
-/// Based on https://www.honlsoft.com/blog/2022-03-26-unit-testing-reading-reference-data/.
-///
-public static class TestUtils
-{
- public static string ReadFileAsString(string file, [CallerFilePath] string filePath = "")
- {
- var directoryPath = Path.GetDirectoryName(filePath);
- var fullPath = Path.Join(directoryPath, file);
- return File.ReadAllText(fullPath);
- }
-}
\ No newline at end of file
diff --git a/test/XmpTests.cs b/test/XmpTests.cs
deleted file mode 100644
index 823195f..0000000
--- a/test/XmpTests.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using System.Xml.Linq;
-using System.Xml.XPath;
-using LiveTagger.XmlConstants;
-
-namespace LiveTagger.Tests;
-
-public class XmpTests
-{
- [Fact]
- public void ShouldAddTags()
- {
- var xmp = new Xmp();
-
- xmp.AddTags(
- ["bd1.wav", "bd2.wav"],
- ["Drums|Kick"]
- );
-
- xmp.AddTags(
- ["ch.wav"],
- ["Drums|Hihat", "Drums|Hihat|Closed Hihat"]
- );
-
- xmp.AddTags(
- ["bd1.wav", "bd2.wav", "ch.wav"],
- ["Creator|17cupsofcoffee"]
- );
-
- Assert.Equal(
- XElement.Parse(TestUtils.ReadFileAsString("TestData/WithTagsAdded.xml")),
- xmp.Xml,
- XNode.EqualityComparer
- );
-
- Assert.True(xmp.IsDirty);
- }
-
- [Fact]
- public void ShouldRemoveTags()
- {
- var xmp = Xmp.FromString(TestUtils.ReadFileAsString("TestData/WithTagsAdded.xml"));
-
- xmp.RemoveTags(
- ["bd1.wav", "bd2.wav", "ch.wav"],
- ["Creator|17cupsofcoffee"]
- );
-
- xmp.RemoveTags(
- ["ch.wav"],
- ["Drums|Hihat", "Drums|Hihat|Closed Hihat"]
- );
-
- Assert.Equal(
- XElement.Parse(TestUtils.ReadFileAsString("TestData/WithTagsRemoved.xml")),
- xmp.Xml,
- XNode.EqualityComparer
- );
-
- Assert.True(xmp.IsDirty);
- }
-
- [Fact]
- public void ShouldRemoveAllTags()
- {
- var xmp = Xmp.FromString(TestUtils.ReadFileAsString("TestData/WithTagsAdded.xml"));
-
- xmp.RemoveTags(
- ["ch.wav"]
- );
-
- Assert.Equal(
- XElement.Parse(TestUtils.ReadFileAsString("TestData/WithAllTagsRemoved.xml")),
- xmp.Xml,
- XNode.EqualityComparer
- );
-
- Assert.True(xmp.IsDirty);
- }
-}
From b23ca38b688ce36ad864e0f8df0c0fb323d22758 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Fri, 2 May 2025 20:24:04 +0100
Subject: [PATCH 02/14] Make sure we're building for the right target...
---
.github/workflows/build.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3807dd5..76171bd 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -32,4 +32,4 @@ jobs:
with:
toolchain: stable
target: ${{ matrix.target }}
- - run: cargo test --all-features
+ - run: cargo test --target ${{ matrix.target }}
From ef44f87f309d2788c53627a2ab9d41d05da52e48 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Fri, 2 May 2025 20:27:25 +0100
Subject: [PATCH 03/14] Add build command to CI job
---
.github/workflows/build.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 76171bd..39dc274 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -32,4 +32,5 @@ jobs:
with:
toolchain: stable
target: ${{ matrix.target }}
+ - run: cargo build --target ${{ matrix.target }}
- run: cargo test --target ${{ matrix.target }}
From 42deb8177826b792ca63f4eaadffd39d1398c881 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Fri, 2 May 2025 20:44:10 +0100
Subject: [PATCH 04/14] More CI stuff...
---
.github/workflows/build.yml | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 39dc274..1ec47c5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,11 +14,11 @@ jobs:
strategy:
matrix:
include:
- - build: win-x64
+ - build: Windows (x64)
os: windows-latest
target: x86_64-pc-windows-msvc
- - build: macos-x64
- os: macos-latest
+ - build: MacOS (x64)
+ os: macos-13
target: x86_64-apple-darwin
- build: macos-arm
os: macos-latest
@@ -34,3 +34,7 @@ jobs:
target: ${{ matrix.target }}
- run: cargo build --target ${{ matrix.target }}
- run: cargo test --target ${{ matrix.target }}
+ - uses: actions/upload-artifact@v4
+ with:
+ name: build-${{ matrix.target }}
+ path: target/${{ matrix.target }}/debug/livetagger*
\ No newline at end of file
From 697f06260dec979e6fe26de88d54c082718f42c7 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Fri, 2 May 2025 20:49:57 +0100
Subject: [PATCH 05/14] More CI stuff...
---
.github/workflows/build.yml | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1ec47c5..1bd0504 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -11,18 +11,17 @@ on:
jobs:
build:
+ name: ${{ matrix.job.target }}
+
strategy:
matrix:
include:
- - build: Windows (x64)
+ - target: x86_64-pc-windows-msvc
os: windows-latest
- target: x86_64-pc-windows-msvc
- - build: MacOS (x64)
+ - target: x86_64-apple-darwin
os: macos-13
- target: x86_64-apple-darwin
- - build: macos-arm
+ - target: aarch64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
runs-on: ${{ matrix.os }}
@@ -34,7 +33,3 @@ jobs:
target: ${{ matrix.target }}
- run: cargo build --target ${{ matrix.target }}
- run: cargo test --target ${{ matrix.target }}
- - uses: actions/upload-artifact@v4
- with:
- name: build-${{ matrix.target }}
- path: target/${{ matrix.target }}/debug/livetagger*
\ No newline at end of file
From c6475c72e5edc301959e765fd34f22dc25d2e542 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Fri, 2 May 2025 20:53:21 +0100
Subject: [PATCH 06/14] More CI stuff
---
.github/workflows/build.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1bd0504..78c45a4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -11,7 +11,7 @@ on:
jobs:
build:
- name: ${{ matrix.job.target }}
+ name: ${{ matrix.target }}
strategy:
matrix:
From 57083c875c97c6c8bbce576fc8da99656e08a402 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Sun, 4 May 2025 14:02:42 +0100
Subject: [PATCH 07/14] Reorganize code into library and binary
---
Cargo.lock | 11 +-
Cargo.toml | 10 +-
livemeta/Cargo.toml | 10 ++
{src => livemeta/src}/error.rs | 0
{src/metadata => livemeta/src}/folder.rs | 134 ----------------
src/metadata.rs => livemeta/src/lib.rs | 2 +
{src/metadata => livemeta/src}/sample.rs | 0
.../src}/test_data/initial.xml | 0
.../src}/test_data/tags_added.xml | 0
.../src}/test_data/tags_removed.xml | 0
.../src}/test_data/tags_removed_all.xml | 0
src/commands.rs | 146 ++++++++++++++++++
src/lib.rs | 2 -
src/main.rs | 48 ++----
14 files changed, 188 insertions(+), 175 deletions(-)
create mode 100644 livemeta/Cargo.toml
rename {src => livemeta/src}/error.rs (100%)
rename {src/metadata => livemeta/src}/folder.rs (71%)
rename src/metadata.rs => livemeta/src/lib.rs (96%)
rename {src/metadata => livemeta/src}/sample.rs (100%)
rename {src/metadata => livemeta/src}/test_data/initial.xml (100%)
rename {src/metadata => livemeta/src}/test_data/tags_added.xml (100%)
rename {src/metadata => livemeta/src}/test_data/tags_removed.xml (100%)
rename {src/metadata => livemeta/src}/test_data/tags_removed_all.xml (100%)
create mode 100644 src/commands.rs
delete mode 100644 src/lib.rs
diff --git a/Cargo.lock b/Cargo.lock
index d0a37bc..0f63be8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -213,6 +213,14 @@ version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
+[[package]]
+name = "livemeta"
+version = "0.1.0"
+dependencies = [
+ "pretty_assertions",
+ "xmp_toolkit",
+]
+
[[package]]
name = "livetagger"
version = "0.1.0"
@@ -220,10 +228,9 @@ dependencies = [
"anyhow",
"clap",
"glob",
- "pretty_assertions",
+ "livemeta",
"tracing",
"tracing-subscriber",
- "xmp_toolkit",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 15deec3..444f7f0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,13 +3,15 @@ name = "livetagger"
version = "0.1.0"
edition = "2024"
+[workspace]
+resolver = "3"
+members = ["livemeta"]
+
[dependencies]
+livemeta = { path = "livemeta" }
+
anyhow = "1.0.98"
clap = { version = "4.5.37", features = ["derive"] }
glob = "0.3.2"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
-xmp_toolkit = "1.9.2"
-
-[dev-dependencies]
-pretty_assertions = "1.4.1"
diff --git a/livemeta/Cargo.toml b/livemeta/Cargo.toml
new file mode 100644
index 0000000..d3f2f83
--- /dev/null
+++ b/livemeta/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "livemeta"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+xmp_toolkit = "1.9.2"
+
+[dev-dependencies]
+pretty_assertions = "1.4.1"
\ No newline at end of file
diff --git a/src/error.rs b/livemeta/src/error.rs
similarity index 100%
rename from src/error.rs
rename to livemeta/src/error.rs
diff --git a/src/metadata/folder.rs b/livemeta/src/folder.rs
similarity index 71%
rename from src/metadata/folder.rs
rename to livemeta/src/folder.rs
index 14b96bb..f3ce410 100644
--- a/src/metadata/folder.rs
+++ b/livemeta/src/folder.rs
@@ -1,9 +1,7 @@
-use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
-use tracing::info;
use xmp_toolkit::{FromStrOptions, ToStringOptions, XmpDateTime, XmpMeta, XmpValue, xmp_ns};
use crate::error::{Error, Result};
@@ -38,138 +36,6 @@ pub fn is_folder_metadata(path: &Path) -> bool {
.any(|component| component == "Ableton Folder Info")
}
-/// Adds tags to the specified files.
-///
-/// If an entry for a file does not exist yet in the metadata document, it will be added.
-pub fn add_tags(doc: &mut FolderMetadata, mut files: HashSet, tags: &[String]) -> Result {
- let item_count = doc.item_count();
-
- for i in 1..=item_count {
- let mut tags_added = Vec::new();
-
- let item = ItemSelector::new(i)?;
-
- let filename = doc.get_filename(&item)?;
-
- if files.take(&filename).is_some() {
- let keyword_count = doc.keyword_count(&item);
- let mut keywords = HashSet::new();
-
- for i in 1..=keyword_count {
- keywords.insert(doc.get_keyword(&item, i)?);
- }
-
- for tag in tags {
- if !keywords.contains(tag) {
- doc.push_keyword(&item, tag.clone())?;
- tags_added.push(tag.as_str());
- }
- }
-
- if !tags_added.is_empty() {
- info!("Added tags to {}: {}", filename, tags_added.join(", "));
- }
- }
- }
-
- for (i, new_file) in files.into_iter().enumerate() {
- let mut tags_added = Vec::new();
-
- let item = ItemSelector::new(item_count + i + 1)?;
-
- doc.set_filename(&item, new_file.clone())?;
-
- for tag in tags {
- doc.push_keyword(&item, tag.clone())?;
- tags_added.push(tag.as_str());
- }
-
- if !tags_added.is_empty() {
- info!("Added tags to {}: {}", &new_file, tags_added.join(", "));
- }
- }
-
- Ok(())
-}
-
-/// Removes tags from the specified files.
-///
-/// This will not remove the files themselves from the metadata document, even
-/// if all the keywords are gone - while keywords are currently the only
-/// metadata stored for each file, Ableton could potentially add
-/// additional data in future versions.
-pub fn remove_tags(
- doc: &mut FolderMetadata,
- mut files: HashSet,
- tags: &[String],
-) -> Result {
- let item_count = doc.item_count();
-
- for i in 1..=item_count {
- let mut tags_removed = Vec::new();
-
- let item = ItemSelector::new(i)?;
-
- let filename = doc.get_filename(&item)?;
-
- if files.take(&filename).is_some() {
- let keyword_count = doc.keyword_count(&item);
-
- let mut deleted_count = 0;
-
- // We iterate in reverse to avoid invalidating the indices
- // when elements get deleted.
- for i in (1..=keyword_count).rev() {
- let keyword = doc.get_keyword(&item, i)?;
-
- if tags.contains(&keyword) {
- doc.delete_keyword(&item, i)?;
- tags_removed.push(keyword);
-
- deleted_count += 1;
- }
- }
-
- if deleted_count == keyword_count {
- doc.delete_keywords(&item)?;
- }
-
- if !tags_removed.is_empty() {
- info!(
- "Removed tags from {}: {}",
- &filename,
- tags_removed.join(", ")
- );
- }
- }
- }
-
- Ok(())
-}
-
-/// Removed all tags from the specified files.
-///
-/// This will not remove the files themselves from the metadata document -
-/// while keywords are currently the only metadata stored for each file,
-/// Ableton could potentially add additional data in future versions.
-pub fn remove_all_tags(doc: &mut FolderMetadata, mut files: HashSet) -> Result {
- let item_count = doc.item_count();
-
- for i in 1..=item_count {
- let item = ItemSelector::new(i)?;
-
- let filename = doc.get_filename(&item)?;
-
- if files.take(&filename).is_some() {
- doc.delete_keywords(&item)?;
-
- info!("Removed all tags from {}", &filename);
- }
- }
-
- Ok(())
-}
-
/// Ableton Live metadata for a folder.
#[derive(Debug)]
pub struct FolderMetadata {
diff --git a/src/metadata.rs b/livemeta/src/lib.rs
similarity index 96%
rename from src/metadata.rs
rename to livemeta/src/lib.rs
index e02bd3f..460f526 100644
--- a/src/metadata.rs
+++ b/livemeta/src/lib.rs
@@ -1,6 +1,8 @@
+mod error;
mod folder;
mod sample;
+pub use error::*;
pub use folder::*;
pub use sample::*;
diff --git a/src/metadata/sample.rs b/livemeta/src/sample.rs
similarity index 100%
rename from src/metadata/sample.rs
rename to livemeta/src/sample.rs
diff --git a/src/metadata/test_data/initial.xml b/livemeta/src/test_data/initial.xml
similarity index 100%
rename from src/metadata/test_data/initial.xml
rename to livemeta/src/test_data/initial.xml
diff --git a/src/metadata/test_data/tags_added.xml b/livemeta/src/test_data/tags_added.xml
similarity index 100%
rename from src/metadata/test_data/tags_added.xml
rename to livemeta/src/test_data/tags_added.xml
diff --git a/src/metadata/test_data/tags_removed.xml b/livemeta/src/test_data/tags_removed.xml
similarity index 100%
rename from src/metadata/test_data/tags_removed.xml
rename to livemeta/src/test_data/tags_removed.xml
diff --git a/src/metadata/test_data/tags_removed_all.xml b/livemeta/src/test_data/tags_removed_all.xml
similarity index 100%
rename from src/metadata/test_data/tags_removed_all.xml
rename to livemeta/src/test_data/tags_removed_all.xml
diff --git a/src/commands.rs b/src/commands.rs
new file mode 100644
index 0000000..ffe20a9
--- /dev/null
+++ b/src/commands.rs
@@ -0,0 +1,146 @@
+use std::collections::HashSet;
+
+use livemeta::{FolderMetadata, ItemSelector};
+use tracing::info;
+
+use crate::CommandArgs;
+
+/// Adds tags to the specified files.
+///
+/// If an entry for a file does not exist yet in the metadata document, it will be added.
+pub fn add_tags(
+ args: &CommandArgs,
+ doc: &mut FolderMetadata,
+ mut files: HashSet,
+) -> anyhow::Result<()> {
+ let item_count = doc.item_count();
+
+ for i in 1..=item_count {
+ let mut tags_added = Vec::new();
+
+ let item = ItemSelector::new(i)?;
+
+ let filename = doc.get_filename(&item)?;
+
+ if files.take(&filename).is_some() {
+ let keyword_count = doc.keyword_count(&item);
+ let mut keywords = HashSet::new();
+
+ for i in 1..=keyword_count {
+ keywords.insert(doc.get_keyword(&item, i)?);
+ }
+
+ for tag in &args.tags {
+ if !keywords.contains(tag) {
+ doc.push_keyword(&item, tag.clone())?;
+ tags_added.push(tag.as_str());
+ }
+ }
+
+ if !tags_added.is_empty() {
+ info!("Added tags to {}: {}", filename, tags_added.join(", "));
+ }
+ }
+ }
+
+ for (i, new_file) in files.into_iter().enumerate() {
+ let mut tags_added = Vec::new();
+
+ let item = ItemSelector::new(item_count + i + 1)?;
+
+ doc.set_filename(&item, new_file.clone())?;
+
+ for tag in &args.tags {
+ doc.push_keyword(&item, tag.clone())?;
+ tags_added.push(tag.as_str());
+ }
+
+ if !tags_added.is_empty() {
+ info!("Added tags to {}: {}", &new_file, tags_added.join(", "));
+ }
+ }
+
+ Ok(())
+}
+
+/// Removes tags from the specified files.
+///
+/// This will not remove the files themselves from the metadata document, even
+/// if all the keywords are gone - while keywords are currently the only
+/// metadata stored for each file, Ableton could potentially add
+/// additional data in future versions.
+pub fn remove_tags(
+ args: &CommandArgs,
+ doc: &mut FolderMetadata,
+ mut files: HashSet,
+) -> anyhow::Result<()> {
+ let item_count = doc.item_count();
+
+ for i in 1..=item_count {
+ let mut tags_removed = Vec::new();
+
+ let item = ItemSelector::new(i)?;
+
+ let filename = doc.get_filename(&item)?;
+
+ if files.take(&filename).is_some() {
+ let keyword_count = doc.keyword_count(&item);
+
+ let mut deleted_count = 0;
+
+ // We iterate in reverse to avoid invalidating the indices
+ // when elements get deleted.
+ for i in (1..=keyword_count).rev() {
+ let keyword = doc.get_keyword(&item, i)?;
+
+ if args.tags.contains(&keyword) {
+ doc.delete_keyword(&item, i)?;
+ tags_removed.push(keyword);
+
+ deleted_count += 1;
+ }
+ }
+
+ if deleted_count == keyword_count {
+ doc.delete_keywords(&item)?;
+ }
+
+ if !tags_removed.is_empty() {
+ info!(
+ "Removed tags from {}: {}",
+ &filename,
+ tags_removed.join(", ")
+ );
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Removed all tags from the specified files.
+///
+/// This will not remove the files themselves from the metadata document -
+/// while keywords are currently the only metadata stored for each file,
+/// Ableton could potentially add additional data in future versions.
+pub fn remove_all_tags(
+ _args: &CommandArgs,
+ doc: &mut FolderMetadata,
+ mut files: HashSet,
+) -> anyhow::Result<()> {
+ let item_count = doc.item_count();
+
+ for i in 1..=item_count {
+ let item = ItemSelector::new(i)?;
+
+ let filename = doc.get_filename(&item)?;
+
+ if files.take(&filename).is_some() {
+ doc.delete_keywords(&item)?;
+
+ info!("Removed all tags from {}", &filename);
+ }
+ }
+
+ Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
deleted file mode 100644
index 58d8d18..0000000
--- a/src/lib.rs
+++ /dev/null
@@ -1,2 +0,0 @@
-pub mod error;
-pub mod metadata;
diff --git a/src/main.rs b/src/main.rs
index ae746d5..595dc45 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,5 @@
+mod commands;
+
use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
@@ -8,7 +10,7 @@ use anyhow::Context;
use clap::{Args, Parser, Subcommand};
use glob::glob;
-use livetagger::metadata::{self, FolderMetadata};
+use livemeta::{self, FolderMetadata};
use tracing::info;
#[derive(Parser, Debug)]
@@ -56,44 +58,24 @@ fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_target(false).init();
match cli.command {
- Command::Add(args) => {
- process_xmp(&args.include, args.commit, args.backup, |xmp, files| {
- metadata::add_tags(xmp, files, &args.tags)?;
-
- Ok(())
- })?;
- }
-
- Command::Remove(args) => {
- process_xmp(&args.include, args.commit, args.backup, |xmp, files| {
- metadata::remove_tags(xmp, files, &args.tags)?;
-
- Ok(())
- })?;
- }
-
- Command::RemoveAll(args) => {
- process_xmp(&args.include, args.commit, args.backup, |xmp, files| {
- metadata::remove_all_tags(xmp, files)?;
-
- Ok(())
- })?;
- }
+ Command::Add(args) => process_xmp(&args, commands::add_tags)?,
+ Command::Remove(args) => process_xmp(&args, commands::remove_tags)?,
+ Command::RemoveAll(args) => process_xmp(&args, commands::remove_all_tags)?,
}
Ok(())
}
-fn process_xmp(include: &str, commit: bool, backup: bool, mut action: F) -> anyhow::Result<()>
+fn process_xmp(args: &CommandArgs, mut action: F) -> anyhow::Result<()>
where
- F: FnMut(&mut FolderMetadata, HashSet) -> anyhow::Result<()>,
+ F: FnMut(&CommandArgs, &mut FolderMetadata, HashSet) -> anyhow::Result<()>,
{
- let folders = search_for_files(include)?;
+ let folders = search_for_files(&args.include)?;
for (folder, files) in folders {
info!("Processing {}", folder.display());
- let xmp_path = metadata::get_folder_metadata_path(&folder);
+ let xmp_path = livemeta::get_folder_metadata_path(&folder);
let (mut xmp, new_file) = if xmp_path.exists() {
(FolderMetadata::from_xmp_file(&xmp_path)?, false)
@@ -101,7 +83,7 @@ where
(FolderMetadata::new()?, true)
};
- action(&mut xmp, files)?;
+ action(args, &mut xmp, files)?;
if xmp.is_dirty() {
xmp.set_creator_tool("Updated by LiveTagger")?;
@@ -112,8 +94,8 @@ where
xmp.update_metadata_date()?;
}
- if commit {
- if backup {
+ if args.commit {
+ if args.backup {
let backup_path = xmp_path.with_extension("xmp.bak");
fs::rename(&xmp_path, &backup_path)?;
@@ -138,11 +120,11 @@ fn search_for_files(include: &str) -> anyhow::Result
Date: Sun, 4 May 2025 14:59:54 +0100
Subject: [PATCH 08/14] Fix tests
---
.github/workflows/build.yml | 4 +-
Cargo.lock | 2 +-
Cargo.toml | 3 +
livemeta/Cargo.toml | 3 -
livemeta/src/folder.rs | 75 --------------
src/commands.rs | 99 +++++++++++++++++++
{livemeta/src => src}/test_data/initial.xml | 0
.../src => src}/test_data/tags_added.xml | 0
.../src => src}/test_data/tags_removed.xml | 0
.../test_data/tags_removed_all.xml | 0
10 files changed, 105 insertions(+), 81 deletions(-)
rename {livemeta/src => src}/test_data/initial.xml (100%)
rename {livemeta/src => src}/test_data/tags_added.xml (100%)
rename {livemeta/src => src}/test_data/tags_removed.xml (100%)
rename {livemeta/src => src}/test_data/tags_removed_all.xml (100%)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 78c45a4..e2fba24 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -31,5 +31,5 @@ jobs:
with:
toolchain: stable
target: ${{ matrix.target }}
- - run: cargo build --target ${{ matrix.target }}
- - run: cargo test --target ${{ matrix.target }}
+ - run: cargo build --all --target ${{ matrix.target }}
+ - run: cargo test --all --target ${{ matrix.target }}
diff --git a/Cargo.lock b/Cargo.lock
index 0f63be8..8c67070 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -217,7 +217,6 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
name = "livemeta"
version = "0.1.0"
dependencies = [
- "pretty_assertions",
"xmp_toolkit",
]
@@ -229,6 +228,7 @@ dependencies = [
"clap",
"glob",
"livemeta",
+ "pretty_assertions",
"tracing",
"tracing-subscriber",
]
diff --git a/Cargo.toml b/Cargo.toml
index 444f7f0..5aa0a61 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,3 +15,6 @@ clap = { version = "4.5.37", features = ["derive"] }
glob = "0.3.2"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
+
+[dev-dependencies]
+pretty_assertions = "1.4.1"
diff --git a/livemeta/Cargo.toml b/livemeta/Cargo.toml
index d3f2f83..6a23464 100644
--- a/livemeta/Cargo.toml
+++ b/livemeta/Cargo.toml
@@ -5,6 +5,3 @@ edition = "2024"
[dependencies]
xmp_toolkit = "1.9.2"
-
-[dev-dependencies]
-pretty_assertions = "1.4.1"
\ No newline at end of file
diff --git a/livemeta/src/folder.rs b/livemeta/src/folder.rs
index f3ce410..fd806df 100644
--- a/livemeta/src/folder.rs
+++ b/livemeta/src/folder.rs
@@ -231,79 +231,4 @@ mod tests {
fn should_ignore_non_metadata_file() {
assert!(!is_folder_metadata(Path::new("C:/foo/sound.wav")));
}
-
- #[test]
- fn should_get_folder_metadata_path() {
- assert_eq!(
- get_folder_metadata_path(Path::new("C:/foo")),
- PathBuf::from("C:/foo/Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp")
- );
- }
-
- #[test]
- fn should_add_tags() -> Result {
- let initial = include_str!("test_data/initial.xml");
- let expected = include_str!("test_data/tags_added.xml");
-
- let mut meta = FolderMetadata::from_xmp_str(initial)?;
-
- let mut files = HashSet::new();
-
- files.insert("bd1.wav".into());
- files.insert("bd2.wav".into());
- files.insert("bd3.wav".into());
-
- let tags = ["Drums|Kick".into(), "CustomTag".into()];
-
- add_tags(&mut meta, files, &tags)?;
-
- assert!(meta.is_dirty());
- pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
-
- Ok(())
- }
-
- #[test]
- fn should_remove_tags() -> Result {
- let initial = include_str!("test_data/initial.xml");
- let expected = include_str!("test_data/tags_removed.xml");
-
- let mut meta = FolderMetadata::from_xmp_str(initial)?;
-
- let mut files = HashSet::new();
-
- files.insert("bd1.wav".into());
- files.insert("bd2.wav".into());
- files.insert("bd3.wav".into());
-
- let tags = ["Creator|17cupsofcoffee".into(), "NonExistentTag".into()];
-
- remove_tags(&mut meta, files, &tags)?;
-
- assert!(meta.is_dirty());
- pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
-
- Ok(())
- }
-
- #[test]
- fn should_remove_all_tags() -> Result {
- let initial = include_str!("test_data/initial.xml");
- let expected = include_str!("test_data/tags_removed_all.xml");
-
- let mut meta = FolderMetadata::from_xmp_str(initial)?;
-
- let mut files = HashSet::new();
-
- files.insert("bd1.wav".into());
- files.insert("bd2.wav".into());
- files.insert("bd3.wav".into());
-
- remove_all_tags(&mut meta, files)?;
-
- assert!(meta.is_dirty());
- pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
-
- Ok(())
- }
}
diff --git a/src/commands.rs b/src/commands.rs
index ffe20a9..b87f1b5 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -144,3 +144,102 @@ pub fn remove_all_tags(
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_add_tags() -> anyhow::Result<()> {
+ let initial = include_str!("test_data/initial.xml");
+ let expected = include_str!("test_data/tags_added.xml");
+
+ let mut meta = FolderMetadata::from_xmp_str(initial)?;
+
+ let mut files = HashSet::new();
+
+ files.insert("bd1.wav".into());
+ files.insert("bd2.wav".into());
+ files.insert("bd3.wav".into());
+
+ let tags = vec!["Drums|Kick".into(), "CustomTag".into()];
+
+ add_tags(
+ &CommandArgs {
+ tags,
+ include: String::new(),
+ commit: true,
+ backup: true,
+ },
+ &mut meta,
+ files,
+ )?;
+
+ assert!(meta.is_dirty());
+ pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
+
+ Ok(())
+ }
+
+ #[test]
+ fn should_remove_tags() -> anyhow::Result<()> {
+ let initial = include_str!("test_data/initial.xml");
+ let expected = include_str!("test_data/tags_removed.xml");
+
+ let mut meta = FolderMetadata::from_xmp_str(initial)?;
+
+ let mut files = HashSet::new();
+
+ files.insert("bd1.wav".into());
+ files.insert("bd2.wav".into());
+ files.insert("bd3.wav".into());
+
+ let tags = vec!["Creator|17cupsofcoffee".into(), "NonExistentTag".into()];
+
+ remove_tags(
+ &CommandArgs {
+ tags,
+ include: String::new(),
+ commit: true,
+ backup: true,
+ },
+ &mut meta,
+ files,
+ )?;
+
+ assert!(meta.is_dirty());
+ pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
+
+ Ok(())
+ }
+
+ #[test]
+ fn should_remove_all_tags() -> anyhow::Result<()> {
+ let initial = include_str!("test_data/initial.xml");
+ let expected = include_str!("test_data/tags_removed_all.xml");
+
+ let mut meta = FolderMetadata::from_xmp_str(initial)?;
+
+ let mut files = HashSet::new();
+
+ files.insert("bd1.wav".into());
+ files.insert("bd2.wav".into());
+ files.insert("bd3.wav".into());
+
+ remove_all_tags(
+ &CommandArgs {
+ tags: Vec::new(),
+ include: String::new(),
+ commit: true,
+ backup: true,
+ },
+ &mut meta,
+ files,
+ )?;
+
+ assert!(meta.is_dirty());
+ pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
+
+ Ok(())
+ }
+}
diff --git a/livemeta/src/test_data/initial.xml b/src/test_data/initial.xml
similarity index 100%
rename from livemeta/src/test_data/initial.xml
rename to src/test_data/initial.xml
diff --git a/livemeta/src/test_data/tags_added.xml b/src/test_data/tags_added.xml
similarity index 100%
rename from livemeta/src/test_data/tags_added.xml
rename to src/test_data/tags_added.xml
diff --git a/livemeta/src/test_data/tags_removed.xml b/src/test_data/tags_removed.xml
similarity index 100%
rename from livemeta/src/test_data/tags_removed.xml
rename to src/test_data/tags_removed.xml
diff --git a/livemeta/src/test_data/tags_removed_all.xml b/src/test_data/tags_removed_all.xml
similarity index 100%
rename from livemeta/src/test_data/tags_removed_all.xml
rename to src/test_data/tags_removed_all.xml
From af256870bbafe180c000c6a4f8c6f963c8cce485 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Sun, 4 May 2025 15:03:15 +0100
Subject: [PATCH 09/14] Use thiserror for error type
Dependencies are already using it, so we might as well.
---
Cargo.lock | 1 +
livemeta/Cargo.toml | 1 +
livemeta/src/error.rs | 44 ++++++++-----------------------------------
3 files changed, 10 insertions(+), 36 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 8c67070..8dc5c45 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -217,6 +217,7 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
name = "livemeta"
version = "0.1.0"
dependencies = [
+ "thiserror",
"xmp_toolkit",
]
diff --git a/livemeta/Cargo.toml b/livemeta/Cargo.toml
index 6a23464..b346a77 100644
--- a/livemeta/Cargo.toml
+++ b/livemeta/Cargo.toml
@@ -4,4 +4,5 @@ version = "0.1.0"
edition = "2024"
[dependencies]
+thiserror = "2.0.12"
xmp_toolkit = "1.9.2"
diff --git a/livemeta/src/error.rs b/livemeta/src/error.rs
index ace0fae..a99cc61 100644
--- a/livemeta/src/error.rs
+++ b/livemeta/src/error.rs
@@ -1,44 +1,16 @@
-use std::fmt::{self, Display};
-
+use thiserror::Error;
use xmp_toolkit::XmpError;
-#[derive(Debug)]
+#[derive(Error, Debug)]
pub enum Error {
- Io(std::io::Error),
- Xmp(XmpError),
- MissingField(&'static str),
-}
+ #[error("io error")]
+ Io(#[from] std::io::Error),
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Error::Io(e) => Some(e),
- Error::Xmp(e) => Some(e),
- Error::MissingField(_) => None,
- }
- }
-}
+ #[error("xmp error")]
+ Xmp(#[from] XmpError),
-impl Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Error::Io(_) => write!(f, "io error"),
- Error::Xmp(_) => write!(f, "xmp error"),
- Error::MissingField(field) => write!(f, "missing field: {}", field),
- }
- }
-}
-
-impl From for Error {
- fn from(value: std::io::Error) -> Self {
- Error::Io(value)
- }
-}
-
-impl From for Error {
- fn from(value: XmpError) -> Self {
- Error::Xmp(value)
- }
+ #[error("missing field: {0}")]
+ MissingField(&'static str),
}
pub type Result = std::result::Result;
From 50d860a76bdeeecac22baa94eddd39a407d66758 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Tue, 6 May 2025 23:39:10 +0100
Subject: [PATCH 10/14] Restructure the code so subcommands only take the args
they need
---
src/commands.rs | 51 +++++++++-------------------------------------
src/main.rs | 54 ++++++++++++++++++++++++++++++++-----------------
2 files changed, 45 insertions(+), 60 deletions(-)
diff --git a/src/commands.rs b/src/commands.rs
index b87f1b5..07ccd50 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -3,15 +3,13 @@ use std::collections::HashSet;
use livemeta::{FolderMetadata, ItemSelector};
use tracing::info;
-use crate::CommandArgs;
-
/// Adds tags to the specified files.
///
/// If an entry for a file does not exist yet in the metadata document, it will be added.
pub fn add_tags(
- args: &CommandArgs,
doc: &mut FolderMetadata,
mut files: HashSet,
+ tags: &[String],
) -> anyhow::Result<()> {
let item_count = doc.item_count();
@@ -30,7 +28,7 @@ pub fn add_tags(
keywords.insert(doc.get_keyword(&item, i)?);
}
- for tag in &args.tags {
+ for tag in tags {
if !keywords.contains(tag) {
doc.push_keyword(&item, tag.clone())?;
tags_added.push(tag.as_str());
@@ -50,7 +48,7 @@ pub fn add_tags(
doc.set_filename(&item, new_file.clone())?;
- for tag in &args.tags {
+ for tag in tags {
doc.push_keyword(&item, tag.clone())?;
tags_added.push(tag.as_str());
}
@@ -70,9 +68,9 @@ pub fn add_tags(
/// metadata stored for each file, Ableton could potentially add
/// additional data in future versions.
pub fn remove_tags(
- args: &CommandArgs,
doc: &mut FolderMetadata,
mut files: HashSet,
+ tags: &[String],
) -> anyhow::Result<()> {
let item_count = doc.item_count();
@@ -93,7 +91,7 @@ pub fn remove_tags(
for i in (1..=keyword_count).rev() {
let keyword = doc.get_keyword(&item, i)?;
- if args.tags.contains(&keyword) {
+ if tags.contains(&keyword) {
doc.delete_keyword(&item, i)?;
tags_removed.push(keyword);
@@ -123,11 +121,7 @@ pub fn remove_tags(
/// This will not remove the files themselves from the metadata document -
/// while keywords are currently the only metadata stored for each file,
/// Ableton could potentially add additional data in future versions.
-pub fn remove_all_tags(
- _args: &CommandArgs,
- doc: &mut FolderMetadata,
- mut files: HashSet,
-) -> anyhow::Result<()> {
+pub fn remove_all_tags(doc: &mut FolderMetadata, mut files: HashSet) -> anyhow::Result<()> {
let item_count = doc.item_count();
for i in 1..=item_count {
@@ -162,18 +156,7 @@ mod tests {
files.insert("bd2.wav".into());
files.insert("bd3.wav".into());
- let tags = vec!["Drums|Kick".into(), "CustomTag".into()];
-
- add_tags(
- &CommandArgs {
- tags,
- include: String::new(),
- commit: true,
- backup: true,
- },
- &mut meta,
- files,
- )?;
+ add_tags(&mut meta, files, &["Drums|Kick".into(), "CustomTag".into()])?;
assert!(meta.is_dirty());
pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
@@ -194,17 +177,10 @@ mod tests {
files.insert("bd2.wav".into());
files.insert("bd3.wav".into());
- let tags = vec!["Creator|17cupsofcoffee".into(), "NonExistentTag".into()];
-
remove_tags(
- &CommandArgs {
- tags,
- include: String::new(),
- commit: true,
- backup: true,
- },
&mut meta,
files,
+ &["Creator|17cupsofcoffee".into(), "NonExistentTag".into()],
)?;
assert!(meta.is_dirty());
@@ -226,16 +202,7 @@ mod tests {
files.insert("bd2.wav".into());
files.insert("bd3.wav".into());
- remove_all_tags(
- &CommandArgs {
- tags: Vec::new(),
- include: String::new(),
- commit: true,
- backup: true,
- },
- &mut meta,
- files,
- )?;
+ remove_all_tags(&mut meta, files)?;
assert!(meta.is_dirty());
pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n"));
diff --git a/src/main.rs b/src/main.rs
index 595dc45..1db1f4b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -24,53 +24,70 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Command {
/// Adds tags to a set of files.
- Add(CommandArgs),
+ Add(TagChangeArgs),
/// Removes tags from a set of files.
- Remove(CommandArgs),
+ Remove(TagChangeArgs),
/// Removes all tags from a set of files.
- RemoveAll(CommandArgs),
+ RemoveAll(FilesystemArgs),
}
+/// CLI flags for operating on files.
#[derive(Args, Debug)]
-struct CommandArgs {
- /// The tags to apply to the matched files.
- #[arg(required(true))]
- tags: Vec,
-
+struct FilesystemArgs {
/// A glob pattern specifying which files should be processed.
- #[arg(short, long, value_name = "GLOB", default_value = "*")]
+ #[arg(short, long, global(true), value_name = "GLOB", default_value = "*")]
include: String,
/// Saves changes to the filesystem. Run without this first, to make sure you're tagging the correct files!
- #[arg(short, long)]
+ #[arg(short, long, global(true))]
commit: bool,
/// Creates backups of any changed metadata.
- #[arg(short, long)]
+ #[arg(short, long, global(true))]
backup: bool,
}
+/// CLI flags for batch tag operations.
+#[derive(Args, Debug)]
+struct TagChangeArgs {
+ /// The tags to apply to the matched files.
+ #[arg(required(true))]
+ tags: Vec,
+
+ #[command(flatten)]
+ fs: FilesystemArgs,
+}
+
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
tracing_subscriber::fmt().with_target(false).init();
match cli.command {
- Command::Add(args) => process_xmp(&args, commands::add_tags)?,
- Command::Remove(args) => process_xmp(&args, commands::remove_tags)?,
+ Command::Add(args) => process_xmp(&args.fs, |doc, files| {
+ commands::add_tags(doc, files, &args.tags)
+ })?,
+
+ Command::Remove(args) => process_xmp(&args.fs, |doc, files| {
+ commands::remove_tags(doc, files, &args.tags)
+ })?,
+
Command::RemoveAll(args) => process_xmp(&args, commands::remove_all_tags)?,
}
Ok(())
}
-fn process_xmp(args: &CommandArgs, mut action: F) -> anyhow::Result<()>
+/// Finds all files matching the provided parameters, applies some logic to each folder's
+/// metadata document (creating one from scratch if needed), then saves to disk if
+/// changes have been made.
+fn process_xmp(args: &FilesystemArgs, mut action: F) -> anyhow::Result<()>
where
- F: FnMut(&CommandArgs, &mut FolderMetadata, HashSet) -> anyhow::Result<()>,
+ F: FnMut(&mut FolderMetadata, HashSet) -> anyhow::Result<()>,
{
- let folders = search_for_files(&args.include)?;
+ let folders = search_for_sample_folders(&args.include)?;
for (folder, files) in folders {
info!("Processing {}", folder.display());
@@ -83,7 +100,7 @@ where
(FolderMetadata::new()?, true)
};
- action(args, &mut xmp, files)?;
+ action(&mut xmp, files)?;
if xmp.is_dirty() {
xmp.set_creator_tool("Updated by LiveTagger")?;
@@ -114,7 +131,8 @@ where
Ok(())
}
-fn search_for_files(include: &str) -> anyhow::Result>> {
+/// Finds all sample files matching a given glob, as well as their corresponding parent folders.
+fn search_for_sample_folders(include: &str) -> anyhow::Result>> {
let mut folders: HashMap> = HashMap::new();
for entry in glob(include).context("Invalid include glob")? {
From ac91ca67cc2f0d6c1a160332e93f55b25fe32f5f Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Tue, 6 May 2025 23:47:21 +0100
Subject: [PATCH 11/14] Update readme
---
README.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index a001935..d9cecf2 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,8 @@ This command has various options you can use to tweak the behaviour:
* `--commit` (or `-c`) makes the command save its changes.
* Without this, the command will just log what files would be impacted.
* **I strongly suggest running without `--commit` before making any big changes, to make sure the command is going to do what you're expecting!**
+* `--backup` (or `-b`) will create a backup of any files that are changed.
+ * To restore a backup, go to the `Ableton Folder Info` subdirectory next to the files you tagged, and rename the `.xmp.bak` file to `.xmp`.
There are also several other commands available:
@@ -47,5 +49,4 @@ If you specify a category/tag/subtag that does not exist, Live will create it au
## Notes
-* This tool works by manually modifying the XMP metadata files that Live creates. If Ableton change the format of those files, this tool will probably break!
-* Whenever LiveTagger makes a change to the metadata, it will write a backup first. If you need to revert the changes, go to the `Ableton Folder Info` subfolder of the folder you ran the tool on, and replace the `.xmp` file with the backup. Later versions may add commands for this.
+* This tool works by manually modifying the XMP metadata files that Live creates. If Ableton change the format of those files, this tool may break!
From cc8095c9e3018935861259fb9c6b36c71770375a Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Wed, 7 May 2025 00:01:10 +0100
Subject: [PATCH 12/14] Make it clearer that changes are not applied without
--commit
---
src/commands.rs | 8 ++++----
src/main.rs | 6 +++++-
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/commands.rs b/src/commands.rs
index 07ccd50..1e407ac 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -36,7 +36,7 @@ pub fn add_tags(
}
if !tags_added.is_empty() {
- info!("Added tags to {}: {}", filename, tags_added.join(", "));
+ info!("Adding tags to {}: {}", filename, tags_added.join(", "));
}
}
}
@@ -54,7 +54,7 @@ pub fn add_tags(
}
if !tags_added.is_empty() {
- info!("Added tags to {}: {}", &new_file, tags_added.join(", "));
+ info!("Adding tags to {}: {}", &new_file, tags_added.join(", "));
}
}
@@ -105,7 +105,7 @@ pub fn remove_tags(
if !tags_removed.is_empty() {
info!(
- "Removed tags from {}: {}",
+ "Removing tags from {}: {}",
&filename,
tags_removed.join(", ")
);
@@ -132,7 +132,7 @@ pub fn remove_all_tags(doc: &mut FolderMetadata, mut files: HashSet) ->
if files.take(&filename).is_some() {
doc.delete_keywords(&item)?;
- info!("Removed all tags from {}", &filename);
+ info!("Removing all tags from {}", &filename);
}
}
diff --git a/src/main.rs b/src/main.rs
index 1db1f4b..54fd9fc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,7 +11,7 @@ use clap::{Args, Parser, Subcommand};
use glob::glob;
use livemeta::{self, FolderMetadata};
-use tracing::info;
+use tracing::{info, warn};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
@@ -128,6 +128,10 @@ where
}
}
+ if !args.commit {
+ warn!("Run again with --commit to apply the above changes!");
+ }
+
Ok(())
}
From 0e7ecb92a7c36ba48b1ad5bfd523d5b9e4adcc7b Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Wed, 7 May 2025 12:42:23 +0100
Subject: [PATCH 13/14] Add release workflow
---
.github/workflows/build.yml | 7 +++--
.github/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++++
2 files changed, 58 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/release.yml
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e2fba24..8b9b1a9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -31,5 +31,8 @@ jobs:
with:
toolchain: stable
target: ${{ matrix.target }}
- - run: cargo build --all --target ${{ matrix.target }}
- - run: cargo test --all --target ${{ matrix.target }}
+ - name: Build
+ run:
+ cargo build --all --target ${{ matrix.target }}
+ - name: Test
+ run: cargo test --all --target ${{ matrix.target }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..40f5e2d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,53 @@
+name: Release
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ release:
+ name: ${{ matrix.target }}
+
+ permissions:
+ contents: write
+
+ strategy:
+ matrix:
+ include:
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+ - target: x86_64-apple-darwin
+ os: macos-13
+ - target: aarch64-apple-darwin
+ os: macos-latest
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: stable
+ target: ${{ matrix.target }}
+ - name: Build
+ run: |
+ cargo build --release --target ${{ matrix.target }}
+
+ if [ "${{ matrix.os }}" = "windows-latest" ]; then
+ bin="target/${{ matrix.target }}/release/livetagger.exe"
+ else
+ bin="target/${{ matrix.target }}/release/livetagger"
+ fi
+
+ echo "BIN=$bin" >> $GITHUB_ENV
+ - name: Zip artifacts
+ run: |
+ tag=$(git describe --tags --abbrev=0)
+
+ 7z a "livetagger-$tag-$target-x64.zip" $bin
+ - name: Publish
+ uses: softprops/action-gh-release@v2
+ with:
+ files: "livetagger*"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
From 661fe252ad92519ae4d81c4c0f655159188ae2e8 Mon Sep 17 00:00:00 2001
From: Joe Clay <27cupsofcoffee@gmail.com>
Date: Wed, 7 May 2025 12:46:10 +0100
Subject: [PATCH 14/14] Strip release binaries
---
Cargo.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/Cargo.toml b/Cargo.toml
index 5aa0a61..81bcd21 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,3 +18,6 @@ tracing-subscriber = "0.3.19"
[dev-dependencies]
pretty_assertions = "1.4.1"
+
+[profile.release]
+strip = true
\ No newline at end of file