diff --git a/CLAUDE.md b/CLAUDE.md index 17d52400..5d8e4f85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,7 @@ When creating GitHub issues, always check available labels, projects, and milest ```bash # List all available labels -gh api repos/Shopify/roast/labels | jq '.[].name' +gh label list --repo Shopify/roast --json name --jq '.[].name' # List all milestones gh api repos/Shopify/roast/milestones | jq '.[] | {title: .title, number: .number, state: .state}' diff --git a/README.md b/README.md index 1687a819..323509e1 100644 --- a/README.md +++ b/README.md @@ -528,8 +528,8 @@ This feature is particularly useful when: - Resuming after failures in long-running workflows **Storage Locations:** -- SQLite: `~/.roast/sessions.db` (configurable via `ROAST_SESSIONS_DB`) -- Filesystem: `.roast/sessions/` directory in your project +- SQLite: `$XDG_DATA_HOME/roast/sessions.db` (default: `~/.local/share/roast/sessions.db`, configurable via `ROAST_SESSIONS_DB`) +- Filesystem: `$XDG_DATA_HOME/roast/sessions/` (default: `~/.local/share/roast/sessions/`) #### Target Option (`-t, --target`) @@ -1082,10 +1082,11 @@ See the [MCP tools example](examples/mcp/) for complete documentation and more e ### Custom Tools -You can create your own tools using the [Raix function dispatch pattern](https://github.com/OlympiaAI/raix-rails?tab=readme-ov-file#use-of-toolsfunctions). Custom tools should be placed in `.roast/initializers/` (subdirectories are supported): +You can create your own tools using the [Raix function dispatch pattern](https://github.com/OlympiaAI/raix-rails?tab=readme-ov-file#use-of-toolsfunctions). Custom tools should be placed in initializers directories (subdirectories are supported): ```ruby -# .roast/initializers/tools/git_analyzer.rb +# ~/.config/roast/initializers/tools/git_analyzer.rb +# OR {workflow_directory}/initializers/tools/git_analyzer.rb module MyProject module Tools module GitAnalyzer @@ -1133,22 +1134,45 @@ The tool will be available to the AI model during workflow execution, and it can ### Project-specific Configuration -You can extend Roast with project-specific configuration by creating initializers in `.roast/initializers/`. These are automatically loaded when workflows run, allowing you to: +You can extend Roast with project-specific configuration by creating initializers. These are automatically loaded when workflows run, allowing you to: - Add custom instrumentation - Configure monitoring and metrics - Set up project-specific tools - Customize workflow behavior -Example structure: +Roast supports initializers in multiple locations (in priority order): + +1. **Workflow-local initializers**: Place alongside your workflow steps +2. **Global XDG config**: Shared across all projects using XDG Base Directory specification + +Example structures: ``` -your-project/ - ├── .roast/ - │ └── initializers/ - │ ├── metrics.rb - │ ├── logging.rb - │ └── custom_tools.rb +# Workflow-local (highest priority) +your-workflow/ + ├── workflow.yml + ├── analyze_code/ + ├── initializers/ + │ ├── metrics.rb + │ ├── logging.rb + │ └── custom_tools.rb └── ... + +# Global XDG config (user-wide) +~/.config/roast/ + └── initializers/ + ├── shopify_defaults.rb + └── custom_tools.rb +``` + +**XDG Configuration Directories:** +- Config: `$XDG_CONFIG_HOME/roast` (default: `~/.config/roast`) +- Cache: `$XDG_CACHE_HOME/roast` (default: `~/.cache/roast`) + +You can customize these locations by setting XDG environment variables: +```bash +export XDG_CONFIG_HOME="/custom/config" +export XDG_CACHE_HOME="/fast/ssd/cache" ``` ### Pre/Post Processing Framework diff --git a/lib/roast.rb b/lib/roast.rb index 413519cd..6fd0499e 100644 --- a/lib/roast.rb +++ b/lib/roast.rb @@ -40,11 +40,29 @@ # Set up Zeitwerk autoloader loader = Zeitwerk::Loader.for_gem + +# Configure custom inflector for XDG acronym +loader.inflector.inflect("xdg_migration" => "XDGMigration") + loader.setup module Roast ROOT = File.expand_path("../..", __FILE__) + # https://specifications.freedesktop.org/basedir-spec/latest/ + XDG_CONFIG_HOME = ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")) + XDG_CACHE_HOME = ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache")) + XDG_DATA_HOME = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")) + + CONFIG_DIR = File.join(XDG_CONFIG_HOME, "roast") + CACHE_DIR = File.join(XDG_CACHE_HOME, "roast") + DATA_DIR = File.join(XDG_DATA_HOME, "roast") + + GLOBAL_INITIALIZERS_DIR = File.join(CONFIG_DIR, "initializers") + FUNCTION_CACHE_DIR = File.join(CACHE_DIR, "function_calls") + SESSION_DATA_DIR = File.join(DATA_DIR, "sessions") + SESSION_DB_PATH = ENV.fetch("ROAST_SESSIONS_DB", File.join(DATA_DIR, "sessions.db")) + class CLI < Thor desc "execute [WORKFLOW_CONFIGURATION_FILE] [FILES...]", "Run a configured workflow" option :concise, type: :boolean, aliases: "-c", desc: "Optional flag for use in output templates" @@ -66,6 +84,8 @@ def execute(*paths) File.expand_path("roast/#{workflow_path}/workflow.yml") end + Roast::XDGMigration.warn_if_migration_needed(expanded_workflow_path) + raise Thor::Error, "Expected a Roast workflow configuration file, got directory: #{expanded_workflow_path}" if File.directory?(expanded_workflow_path) Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin! @@ -82,6 +102,8 @@ def resume(workflow_path) File.expand_path("roast/#{workflow_path}/workflow.yml") end + Roast::XDGMigration.warn_if_migration_needed(expanded_workflow_path) + unless File.exist?(expanded_workflow_path) raise Thor::Error, "Workflow file not found: #{expanded_workflow_path}" end @@ -141,6 +163,8 @@ def list puts "Available workflows:" puts + Roast::XDGMigration.warn_if_migration_needed + workflow_files.each do |file| workflow_name = File.dirname(file.sub("#{roast_dir}/", "")) puts " #{workflow_name} (from project)" @@ -153,6 +177,8 @@ def list desc "validate [WORKFLOW_CONFIGURATION_FILE]", "Validate a workflow configuration" option :strict, type: :boolean, aliases: "-s", desc: "Treat warnings as errors" def validate(workflow_path = nil) + Roast::XDGMigration.warn_if_migration_needed(workflow_path) + validation_command = Roast::Workflow::ValidationCommand.new(options) validation_command.execute(workflow_path) end @@ -204,6 +230,8 @@ def sessions desc "session SESSION_ID", "Show details for a specific session" def session(session_id) + Roast::XDGMigration.warn_if_migration_needed + repository = Workflow::StateRepositoryFactory.create unless repository.respond_to?(:get_session_details) @@ -254,6 +282,8 @@ def session(session_id) desc "diagram WORKFLOW_FILE", "Generate a visual diagram of a workflow" option :output, type: :string, aliases: "-o", desc: "Output file path (defaults to workflow_name_diagram.png)" def diagram(workflow_file) + Roast::XDGMigration.warn_if_migration_needed(workflow_file) + unless File.exist?(workflow_file) raise Thor::Error, "Workflow file not found: #{workflow_file}" end @@ -267,8 +297,59 @@ def diagram(workflow_file) raise Thor::Error, "Error generating diagram: #{e.message}" end + desc "xdg-migrate [WORKFLOW_PATH]", "Migrate legacy .roast directories to XDG directories" + option :auto_confirm, type: :boolean, aliases: "-a", desc: "Automatically confirm all prompts" + def xdg_migrate(workflow_path = nil) + workflow_file_path = if workflow_path.nil? + Roast::Helpers::Logger.warn(::CLI::UI.fmt("{{yellow:No workflow path provided, will not be able to migrate initializers.}}")) + nil + elsif workflow_path.include?("workflow.yml") + File.expand_path(workflow_path) + else + File.expand_path("roast/#{workflow_path}/workflow.yml") + end + + if workflow_file_path && !File.exist?(workflow_file_path) + raise Thor::Error, "Workflow file not found: #{workflow_file_path}" + end + + workflow_context_path = File.dirname(workflow_file_path) if workflow_file_path + + roast_dirs = Roast::XDGMigration.find_all_legacy_dot_roast_dirs(workflow_context_path || Dir.pwd) + dot_roast_path = run_roast_dir_picker(roast_dirs) unless roast_dirs.empty? + + if dot_roast_path.nil? + Roast::Helpers::Logger.info("No legacy .roast directories found.") + return + end + + # Migrate specific .roast directory + migration = Roast::XDGMigration.new( + dot_roast_path:, + workflow_context_path:, + auto_confirm: options[:auto_confirm], + ) + + migration.migrate + end + private + def run_roast_dir_picker(roast_dirs) + choices = roast_dirs.map { |dir| File.dirname(dir) + "/.roast" } + choices << "Cancel" + + selected = ::CLI::UI::Prompt.ask("Select a .roast directory to migrate:") do |handler| + choices.each { |choice| handler.option(choice) { |selection| selection } } + end + + return if selected == "Cancel" + + # Find the full path for the selected directory + selected_index = choices.index(selected) + roast_dirs[selected_index] + end + def show_example_picker examples = available_examples diff --git a/lib/roast/helpers/function_caching_interceptor.rb b/lib/roast/helpers/function_caching_interceptor.rb index 5bffc45c..3d77e606 100644 --- a/lib/roast/helpers/function_caching_interceptor.rb +++ b/lib/roast/helpers/function_caching_interceptor.rb @@ -14,11 +14,11 @@ def dispatch_tool_function(function_name, params) }) # Handle workflows with or without configuration - result = if !respond_to?(:configuration) || configuration.nil? + result = if !respond_to?(:workflow_configuration) || workflow_configuration.nil? super(function_name, params) else - function_config = if configuration.respond_to?(:function_config) - configuration.function_config(function_name) + function_config = if workflow_configuration.respond_to?(:function_config) + workflow_configuration.function_config(function_name) else {} end diff --git a/lib/roast/helpers/logger.rb b/lib/roast/helpers/logger.rb index 4e9f4cc4..70996ce1 100644 --- a/lib/roast/helpers/logger.rb +++ b/lib/roast/helpers/logger.rb @@ -51,7 +51,7 @@ def create_logger(stdout) msg_string = format_message(msg) if severity == "INFO" && !msg_string.start_with?("[") - msg_string + msg_string + "\n" else "[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg_string.gsub(/^\[|\]$/, "").strip}\n" end diff --git a/lib/roast/initializers.rb b/lib/roast/initializers.rb index 5928ecfe..9933a1c9 100644 --- a/lib/roast/initializers.rb +++ b/lib/roast/initializers.rb @@ -3,36 +3,48 @@ module Roast class Initializers class << self - def config_root(starting_path = Dir.pwd, ending_path = File.dirname(Dir.home)) - paths = [] - candidate = starting_path - while candidate != ending_path - paths << File.join(candidate, ".roast") - candidate = File.dirname(candidate) + def load_all(workflow_context_path = Dir.pwd) + # .reverse so we load the highest priority files last, letting them override lower priority files + initializer_files(workflow_context_path).reverse.each do |file| + load_initializer(file) end - - first_existing = paths.find { |path| Dir.exist?(path) } - first_existing || paths.first + rescue => e + puts "ERROR: Error loading initializers: #{e.message}" + Roast::Helpers::Logger.error("Error loading initializers: #{e.message}") + # Don't fail the workflow if initializers can't be loaded end - def initializers_path - File.join(Roast::Initializers.config_root, "initializers") - end + private - def load_all - project_initializers = Roast::Initializers.initializers_path - return unless Dir.exist?(project_initializers) + # Get all possible initializer directories in priority order + def initializer_files(workflow_context_path = Dir.pwd) + initializer_files = [] + # 1. Workflow-local initializers (highest priority) + local_dir = local_initializers_dir(workflow_context_path) + if Dir.exist?(local_dir) + initializer_files.concat(Dir.glob(File.join(local_dir, "**/*.rb"))) + end - $stderr.puts "Loading project initializers from #{project_initializers}" - pattern = File.join(project_initializers, "**/*.rb") - Dir.glob(pattern, sort: true).each do |file| - $stderr.puts "Loading initializer: #{file}" - require file + # 2. XDG global config initializers + if Dir.exist?(Roast::GLOBAL_INITIALIZERS_DIR) + initializer_files.concat(Dir.glob(File.join(Roast::GLOBAL_INITIALIZERS_DIR, "**/*.rb"))) end - rescue => e - puts "ERROR: Error loading initializers: #{e.message}" - Roast::Helpers::Logger.error("Error loading initializers: #{e.message}") - # Don't fail the workflow if initializers can't be loaded + + # 3. Legacy .roast directory support + initializer_files.concat(Roast::XDGMigration.new.legacy_initializers) + + unique_initializer_files = initializer_files.uniq { |file| File.basename(file) } + + unique_initializer_files + end + + def local_initializers_dir(workflow_context_path) + File.join(workflow_context_path, "initializers") + end + + def load_initializer(file) + Roast::Helpers::Logger.info("Loading initializer: #{file}") + require file end end end diff --git a/lib/roast/tools.rb b/lib/roast/tools.rb index 46929a9b..c62c65e5 100644 --- a/lib/roast/tools.rb +++ b/lib/roast/tools.rb @@ -4,15 +4,8 @@ module Roast module Tools extend self - # Initialize cache and ensure .gitignore exists - cache_dir = File.join(Dir.pwd, ".roast", "cache") - FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir) - - # Add .gitignore to cache directory - gitignore_path = File.join(cache_dir, ".gitignore") - File.write(gitignore_path, "*") unless File.exist?(gitignore_path) - - CACHE = ActiveSupport::Cache::FileStore.new(cache_dir) + # Initialize cache using XDG cache directory + CACHE = ActiveSupport::Cache::FileStore.new(FUNCTION_CACHE_DIR) def file_to_prompt(file) <<~PROMPT diff --git a/lib/roast/workflow/session_manager.rb b/lib/roast/workflow/session_manager.rb index e855bfe4..c1a2750d 100644 --- a/lib/roast/workflow/session_manager.rb +++ b/lib/roast/workflow/session_manager.rb @@ -71,7 +71,7 @@ def workflow_directory(session_name, file_path) file_id = Digest::MD5.hexdigest(file_path || Dir.pwd) file_basename = File.basename(file_path || Dir.pwd).parameterize.underscore human_readable_id = "#{file_basename}_#{file_id[0..7]}" - File.join(Dir.pwd, ".roast", "sessions", workflow_dir_name, human_readable_id) + File.join(SESSION_DATA_DIR, workflow_dir_name, human_readable_id) end def find_latest_session_directory(workflow_dir) diff --git a/lib/roast/workflow/sqlite_state_repository.rb b/lib/roast/workflow/sqlite_state_repository.rb index cc1068cf..ae06c44d 100644 --- a/lib/roast/workflow/sqlite_state_repository.rb +++ b/lib/roast/workflow/sqlite_state_repository.rb @@ -7,8 +7,6 @@ module Workflow # SQLite-based implementation of StateRepository # Provides structured, queryable session storage with better performance class SqliteStateRepository < StateRepository - DEFAULT_DB_PATH = File.expand_path("~/.roast/sessions.db") - def initialize(db_path: nil, session_manager: SessionManager.new) super() @@ -19,7 +17,17 @@ def initialize(db_path: nil, session_manager: SessionManager.new) raise LoadError, "SQLite storage requires the 'sqlite3' gem. Please add it to your Gemfile or install it: gem install sqlite3" end - @db_path = db_path || ENV["ROAST_SESSIONS_DB"] || DEFAULT_DB_PATH + # Priority order + paths = [ + db_path, + Roast::SESSION_DB_PATH, + Roast::XDGMigration.new.legacy_sessions_db_path, + ] + + # If multiple options exist, prefer the first one that exists + @db_path = paths.find { |path| path && File.exist?(path) } + # If no options exist as paths, use the first one that is not nil + @db_path ||= paths.find { |path| !path.nil? } @session_manager = session_manager ensure_database end diff --git a/lib/roast/workflow/workflow_initializer.rb b/lib/roast/workflow/workflow_initializer.rb index 37265bfd..500a6b3d 100644 --- a/lib/roast/workflow/workflow_initializer.rb +++ b/lib/roast/workflow/workflow_initializer.rb @@ -18,7 +18,7 @@ def setup private def load_roast_initializers - Roast::Initializers.load_all + Roast::Initializers.load_all(@configuration.context_path) end def check_raix_configuration diff --git a/lib/roast/xdg_migration.rb b/lib/roast/xdg_migration.rb new file mode 100644 index 00000000..d6f42b1b --- /dev/null +++ b/lib/roast/xdg_migration.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +module Roast + class XDGMigration + def initialize(dot_roast_path: nil, workflow_context_path: nil, auto_confirm: false) + @dot_roast_path = dot_roast_path || self.class.find_all_legacy_dot_roast_dirs.first + @workflow_context_path = workflow_context_path + @auto_confirm = auto_confirm + end + + class << self + # Class method to show warnings for any .roast directories found + def warn_if_migration_needed(workflow_file_path = nil) + workflow_context_path = extract_context_path(workflow_file_path) + migration = new(workflow_context_path:) + migration.warn_if_migration_needed + end + + # Find all legacy .roast directories in ancestor tree + def find_all_legacy_dot_roast_dirs(starting_path = Dir.pwd, ending_path = File.dirname(Dir.home)) + found_dirs = [] + candidate = starting_path + + until candidate == ending_path || candidate == "/" + dot_roast_candidate = File.join(candidate, ".roast") + found_dirs << dot_roast_candidate if Dir.exist?(dot_roast_candidate) + + candidate = File.dirname(candidate) + end + + found_dirs + end + + private + + def extract_context_path(workflow_path) + return if workflow_path.nil? + + if workflow_path.end_with?("workflow.yml") + File.dirname(workflow_path) + else + workflow_path + end + end + end + + # Shows deprecation warnings for legacy .roast directories without migrating + def warn_if_migration_needed + return unless @dot_roast_path && Dir.exist?(@dot_roast_path) + + return if migratable_candidates.empty? + + Roast::Helpers::Logger.warn(::CLI::UI.fmt(<<~DEPRECATION.chomp)) + {{yellow:⚠️ DEPRECATION WARNING:}} + Found legacy .roast directory at {{cyan:#{@dot_roast_path}}} that should be migrated to XDG directories: + #{migration_strings.join("\n")} + + {{bold:Please run:}} {{cyan:roast xdg-migrate #{@workflow_context_path || "/path/to/your/workflow.yml"}}} {{bold:to migrate your data}} + + Legacy .roast directories are deprecated and support will be removed in a future version. + DEPRECATION + end + + # Handles migration from legacy .roast directories to XDG directories + def migrate + return unless @dot_roast_path && Dir.exist?(@dot_roast_path) + + migrate_legacy_dirs + cleanup_legacy_dirs + cleanup_legacy_dot_roast_dir + + if migration_complete? + Roast::Helpers::Logger.info("Migration complete!") + else + unmigrated_candidates = existing_candidates.values.select { |candidate| !candidate_migrated?(candidate) } + + Roast::Helpers::Logger.info(::CLI::UI.fmt(<<~INCOMPLETE.chomp)) + + {{yellow:Migration incomplete!}} + + The following items were not migrated: + {{yellow:#{unmigrated_candidates.map { |candidate| candidate[:source] }.join("\n")}}} + INCOMPLETE + end + end + + def existing_candidates + @existing_candidates ||= if @dot_roast_path + candidates.select { |_, candidate| candidate_exists?(candidate) } + else + {} + end + end + + def migratable_candidates + @migratable_candidates ||= if @dot_roast_path + existing_candidates.select { |_, candidate| candidate_migratable?(candidate) } + else + {} + end + end + + def legacy_sessions_db_path + candidates&.dig(:sessions_db, :source) + end + + def legacy_initializers + # Use existing_candidates instead of migratable_candidates so we find legacy initializers + # even when they don't have a valid target (no workflow_context_path) + legacy_initializers_path = existing_candidates&.dig(:initializers, :source) + return [] unless legacy_initializers_path && Dir.exist?(legacy_initializers_path) + + Dir.glob(File.join(legacy_initializers_path, "**/*.rb")) + end + + private + + def candidate_exists?(candidate) + return false unless candidate.key?(:source) + + File.exist?(candidate[:source]) + end + + def candidate_migratable?(candidate) + return false unless candidate.key?(:target) + + # For directories, only consider migratable if they're not empty + if candidate[:type] == :directory + return false if Dir.empty?(candidate[:source]) + end + + true + end + + def candidates + return unless @dot_roast_path + + candidates = { + cache: { + source: File.join(@dot_roast_path, "cache"), + target: FUNCTION_CACHE_DIR, + description: "function cache", + type: :directory, + }, + sessions: { + source: File.join(@dot_roast_path, "sessions"), + target: SESSION_DATA_DIR, + description: "session state", + type: :directory, + }, + sessions_db: { + source: File.join(@dot_roast_path, "sessions.db"), + target: SESSION_DB_PATH, + description: "session database", + type: :file, + }, + initializers: { + source: File.join(@dot_roast_path, "initializers"), + description: "initializers", + type: :directory, + }, + } + + if @workflow_context_path + candidates[:initializers][:target] = File.join(@workflow_context_path, "initializers") + end + + candidates + end + + def migration_complete? + existing_candidates.values.all? { |candidate| candidate_migrated?(candidate) } + end + + def migrated_candidates + existing_candidates.values.select { |candidate| candidate_migrated?(candidate) } + end + + def candidate_migrated?(candidate) + return false unless candidate.key?(:target) && File.exist?(candidate[:target]) + + # For each item in the source, check if it exists in the target + Dir.glob(File.join(candidate[:source], "**/*"), File::FNM_DOTMATCH).all? do |source_path| + target_path = File.join(candidate[:target], Pathname.new(source_path).relative_path_from(Pathname.new(candidate[:source]))) + File.exist?(target_path) + end + end + + def migration_strings + migratable_candidates.values.map { |candidate| candidate_to_s(candidate) } + end + + def candidate_to_s(candidate) + ::CLI::UI.fmt(<<~FROM_TO.chomp) + From: {{yellow:#{candidate[:source]}}} + To: {{blue:#{candidate[:target]}}} + FROM_TO + end + + def migrate_legacy_dirs + return if migratable_candidates.empty? + + Roast::Helpers::Logger.info(<<~MIGRATING.chomp) + --- + Items to migrate: + #{migration_strings.join("\n")} + MIGRATING + + return unless @auto_confirm || ::CLI::UI::Prompt.confirm("Would you like to migrate these items?") + + migratable_candidates.values.each do |candidate| + migrate_candidate(candidate) + end + end + + def cleanup_legacy_dirs + return if migratable_candidates.empty? + + Roast::Helpers::Logger.info(<<~CLEANING.chomp) + --- + The following directories have been migrated: + #{migrated_candidates.map { |candidate| candidate[:source] }.join("\n")} + CLEANING + + return unless @auto_confirm || ::CLI::UI::Prompt.confirm("Would you like to delete these directories?") + + migrated_candidates.each do |candidate| + FileUtils.rm_rf(candidate[:source]) + end + end + + def cleanup_legacy_dot_roast_dir + return unless @dot_roast_path && Dir.exist?(@dot_roast_path) + + dot_roast_children = Dir.glob(File.join(@dot_roast_path, "*"), File::FNM_DOTMATCH) + dot_roast_children.reject! { |child| File.basename(child) == "." || File.basename(child) == ".." } + + if dot_roast_children.any? { |child| File.basename(child) == "initializers" } + Roast::Helpers::Logger.info(::CLI::UI.fmt(<<~INITIALIZERS_FOUND.chomp)) + --- + Initializers found in {{yellow:#{@dot_roast_path}}}. + If you still wish to migrate them, you can do so by running: + {{cyan:roast xdg-migrate #{@workflow_context_path || "/path/to/your/workflow.yml"}}} + INITIALIZERS_FOUND + + return + end + + if dot_roast_children.any? + Roast::Helpers::Logger.info(::CLI::UI.fmt(<<~UNEXPECTED_CHILDREN.chomp)) + --- + We cannot delete {{yellow:#{@dot_roast_path}}} because it still has some children: + #{dot_roast_children.map { |child| " {{yellow:#{child}}}" }.join("\n")} + + You can deal with them manually. + UNEXPECTED_CHILDREN + else + msg = ::CLI::UI.fmt(<<~DELETE_DOT_ROAST.chomp) + Looks like {{yellow:#{@dot_roast_path}}} is empty. + Would you like to delete {{yellow:#{@dot_roast_path}}}? + DELETE_DOT_ROAST + + return unless @auto_confirm || ::CLI::UI::Prompt.confirm(msg) + + FileUtils.rm_rf(@dot_roast_path) + end + end + + def migrate_candidate(candidate) + case candidate[:type] + when :directory + Roast::Helpers::Logger.info(<<~MIGRATING.chomp) + --- + Migrating #{candidate[:description]}: + #{candidate_to_s(candidate)} + MIGRATING + + migrate_directory(candidate[:source], candidate[:target], candidate[:description]) + when :file + Roast::Helpers::Logger.info(<<~MIGRATING.chomp) + --- + Migrating #{candidate[:description]}: + #{candidate_to_s(candidate)} + MIGRATING + + migrate_file(candidate[:source], candidate[:target], candidate[:description]) + end + end + + def migrate_cache(legacy_cache_dir) + migrate_directory(legacy_cache_dir, FUNCTION_CACHE_DIR, "function cache") + end + + def migrate_sessions(legacy_sessions_dir) + migrate_directory(legacy_sessions_dir, SESSION_DATA_DIR, "session state") + end + + def migrate_sessions_db(legacy_sessions_db_path) + migrate_file(legacy_sessions_db_path, SESSION_DB_PATH, "session database") + end + + def migrate_directory(source_dir, target_dir, description) + return unless Dir.exist?(source_dir) + + if Dir.exist?(target_dir) && !Dir.empty?(target_dir) + overwrite_msg = "Non empty directory already exists at {{blue:#{target_dir}}}. Do you want to overwrite it?" + return unless @auto_confirm || ::CLI::UI::Prompt.confirm(overwrite_msg) + end + + # Copy all files and subdirectories + Dir.glob(File.join(source_dir, "**/*"), File::FNM_DOTMATCH).each do |source_path| + next if File.basename(source_path) == "." || File.basename(source_path) == ".." + + relative_path = Pathname.new(source_path).relative_path_from(Pathname.new(source_dir)) + target_path = File.join(target_dir, relative_path) + + if File.directory?(source_path) + FileUtils.mkdir_p(target_path) unless Dir.exist?(target_path) + else + FileUtils.mkdir_p(File.dirname(target_path)) unless Dir.exist?(File.dirname(target_path)) + FileUtils.cp(source_path, target_path) + end + end + + Roast::Helpers::Logger.info("✓ Migrated #{Dir.glob(File.join(source_dir, "**/*")).count} items from #{source_dir}") + rescue => e + Roast::Helpers::Logger.error("⚠️ Error migrating #{description}: #{e.message}") + end + + def migrate_file(source_path, target_path, description) + return unless File.exist?(source_path) + + FileUtils.mkdir_p(File.dirname(target_path)) unless File.directory?(File.dirname(target_path)) + + if File.exist?(target_path) + overwrite_msg = "File already exists at #{target_path}. Do you want to overwrite it?" + return unless @auto_confirm || ::CLI::UI::Prompt.confirm(overwrite_msg) + end + + FileUtils.cp(source_path, target_path) + end + end +end diff --git a/test/roast/cli_test.rb b/test/roast/cli_test.rb index fefb961c..831c3285 100644 --- a/test/roast/cli_test.rb +++ b/test/roast/cli_test.rb @@ -3,6 +3,14 @@ require "test_helper" class RoastCLITest < ActiveSupport::TestCase + def setup + super + # Prevent XDG migration from running during CLI tests since they call cli.execute() + # Note: XDG migration now only shows warnings, not automatic migration + # Also stub warn_if_migration_needed to prevent warnings during tests + Roast::XDGMigration.stubs(:warn_if_migration_needed) + end + def test_execute_with_workflow_yml_path workflow_path = "path/to/workflow.yml" expanded_path = File.expand_path(workflow_path) diff --git a/test/roast/initializers_test.rb b/test/roast/initializers_test.rb index f1c2c187..674580b8 100644 --- a/test/roast/initializers_test.rb +++ b/test/roast/initializers_test.rb @@ -3,121 +3,186 @@ require "test_helper" module Roast - class ConfigRootTest < ActiveSupport::TestCase - def ending_path - File.join(Roast::ROOT, "test", "fixtures", "config_root") - end + class InitializersTest < ActiveSupport::TestCase + include XDGHelper - def test_with_no_roast_folder - starting_path = File.join(ending_path, "empty") - path = Roast::Initializers.config_root(starting_path, ending_path) - expected_path = File.join(starting_path, ".roast") - assert_equal(expected_path, path) + def setup + # Need to reset the logger otherwise we get false-positives for capture_io. + Roast::Helpers::Logger.reset + ::CLI::UI.stubs(:confirm).returns(false) + ::CLI::UI::Prompt.stubs(:confirm).returns(false) end - def test_with_shallow_roast_folder - starting_path = File.join(ending_path, "shallow") - path = Roast::Initializers.config_root(starting_path, ending_path) - expected_path = File.join(starting_path, ".roast") - assert_equal(expected_path, path) - end + test "load_all with no initializer files" do + with_fake_xdg_env do |temp_dir| + Dir.chdir(temp_dir) do + refute(Dir.exist?(Roast::GLOBAL_INITIALIZERS_DIR)) + refute(Dir.exist?(File.join(temp_dir, "initializers"))) + refute(Dir.exist?(File.join(temp_dir, ".roast", "initializers"))) - def test_with_nested_roast_folder - starting_path = File.join(ending_path, "deeply", "nested", "start", "folder") - path = Roast::Initializers.config_root(starting_path, ending_path) - expected_path = File.join(ending_path, "deeply", ".roast") - assert_equal(expected_path, path) + Roast::Initializers.expects(:load_initializer).never + Roast::Initializers.load_all + end + end end - end - class InitializersTest < ActiveSupport::TestCase - def path_for_initializers(name) - File.join(Dir.pwd, "test", "fixtures", "initializers", name) - end + test "load_all with initializer file that raises" do + initializer_path = initializers_fixture_path("raises") + expected_file = File.join(initializer_path, "hell.rb") - def test_with_invalid_initializers_folder - initializer_path = path_for_initializers("invalid") + Roast::Initializers.stub(:initializer_files, [expected_file]) do + Roast::Initializers.expects(:load_initializer).with(expected_file).raises(StandardError, "some exception") - Roast::Initializers.stub(:initializers_path, initializer_path) do - out, err = capture_io do + Roast::Helpers::Logger.reset + out, _err = capture_io do Roast::Initializers.load_all end - assert_equal("", out) - assert_equal("", err) + assert_includes(out, "some exception") end end - def test_with_no_initializer_files - initializer_path = path_for_initializers("empty") + test "load_all with an initializer file" do + initializer_path = initializers_fixture_path("single") + expected_file = File.join(initializer_path, "noop.rb") - Roast::Initializers.stub(:initializers_path, initializer_path) do - out, err = capture_io do - Roast::Initializers.load_all - end + Roast::Initializers.stub(:initializer_files, [expected_file]) do + Roast::Initializers.expects(:load_initializer).with(expected_file).once + Roast::Initializers.load_all + end + end - assert_equal("", out) - expected_output = <<~OUTPUT - Loading project initializers from #{initializer_path} - OUTPUT - assert_equal(expected_output, err) + test "load_all with multiple initializer files" do + initializer_path = initializers_fixture_path("multiple") + expected_files = [ + File.join(initializer_path, "first.rb"), + File.join(initializer_path, "second.rb"), + File.join(initializer_path, "third.rb"), + ] + + Roast::Initializers.stub(:initializer_files, expected_files) do + expected_files.each do |file| + Roast::Initializers.expects(:load_initializer).with(file).once + end + Roast::Initializers.load_all end end - def test_with_initializer_file_that_raises - initializer_path = path_for_initializers("raises") + test "load_all prioritizes local over global" do + with_fake_xdg_env do |temp_dir| + temp_dir = File.realpath(temp_dir) - Roast::Initializers.stub(:initializers_path, initializer_path) do - out, err = capture_io do - Roast::Initializers.load_all - end + global_init_file = File.join(Roast::GLOBAL_INITIALIZERS_DIR, "noop.rb") + FileUtils.mkdir_p(File.dirname(global_init_file)) + File.write(global_init_file, "puts 'global initializer'") - expected_output = <<~OUTPUT - ERROR: Error loading initializers: exception class/object expected - OUTPUT - assert_includes(out, expected_output) - expected_stderr = <<~OUTPUT - Loading project initializers from #{initializer_path} - Loading initializer: #{File.join(initializer_path, "hell.rb")} - OUTPUT - assert_equal(expected_stderr, err) + local_init_file = File.join(temp_dir, "initializers", "noop.rb") + FileUtils.mkdir_p(File.dirname(local_init_file)) + File.write(local_init_file, "puts 'local initializer'") + + Dir.chdir(temp_dir) do + Roast::Helpers::Logger.reset + out, _err = capture_io do + Roast::Initializers.load_all + end + + assert_includes(out, "local initializer") + refute_includes(out, "global initializer") + end end end - def test_with_an_initializer_file - initializer_path = path_for_initializers("single") + test "load_all loads in priority order" do + with_fake_xdg_env do |temp_dir| + temp_dir = File.realpath(temp_dir) + + local_init_file = File.join(temp_dir, "initializers", "local.rb") + FileUtils.mkdir_p(File.dirname(local_init_file)) + File.write(local_init_file, "puts 'local initializer'") + + global_init_file = File.join(Roast::GLOBAL_INITIALIZERS_DIR, "global.rb") + FileUtils.mkdir_p(File.dirname(global_init_file)) + File.write(global_init_file, "puts 'global initializer'") + + legacy_init_file = File.join(temp_dir, ".roast", "initializers", "legacy.rb") + FileUtils.mkdir_p(File.dirname(legacy_init_file)) + File.write(legacy_init_file, "puts 'legacy initializer'") + + Dir.chdir(temp_dir) do + sequence("loading initializers") do + Roast::Initializers.expects(:load_initializer).with(legacy_init_file).once + Roast::Initializers.expects(:load_initializer).with(global_init_file).once + Roast::Initializers.expects(:load_initializer).with(local_init_file).once + end - Roast::Initializers.stub(:initializers_path, initializer_path) do - out, err = capture_io do Roast::Initializers.load_all end + end + end + + test "initializer_files overrides global with local initializers" do + with_fake_xdg_env do |temp_dir| + temp_dir = File.realpath(temp_dir) + + global_init_file = File.join(Roast::GLOBAL_INITIALIZERS_DIR, "noop.rb") + FileUtils.mkdir_p(File.dirname(global_init_file)) + File.write(global_init_file, "puts 'global initializer'") - assert_equal("", out) - expected_output = <<~OUTPUT - Loading project initializers from #{initializer_path} - Loading initializer: #{File.join(initializer_path, "noop.rb")} - OUTPUT - assert_equal(expected_output, err) + local_init_file = File.join(temp_dir, "initializers", "noop.rb") + FileUtils.mkdir_p(File.dirname(local_init_file)) + File.write(local_init_file, "puts 'local initializer'") + + Dir.chdir(temp_dir) do + files = Roast::Initializers.send(:initializer_files) + assert_equal([local_init_file], files) + end end end - def test_with_multiple_initializer_files - initializer_path = path_for_initializers("multiple") + test "initializer_files returns files in priority order" do + with_fake_xdg_env do |temp_dir| + temp_dir = File.realpath(temp_dir) - Roast::Initializers.stub(:initializers_path, initializer_path) do - out, err = capture_io do - Roast::Initializers.load_all + # Local > Global > Legacy + + FileUtils.mkdir_p(Roast::GLOBAL_INITIALIZERS_DIR) + global_init_file = File.join(Roast::GLOBAL_INITIALIZERS_DIR, "global.rb") + File.write(global_init_file, "puts 'global initializer'") + + local_init_file = File.join(temp_dir, "initializers", "local.rb") + FileUtils.mkdir_p(File.dirname(local_init_file)) + File.write(local_init_file, "puts 'local initializer'") + + legacy_init_file = File.join(temp_dir, ".roast", "initializers", "legacy.rb") + FileUtils.mkdir_p(File.dirname(legacy_init_file)) + File.write(legacy_init_file, "puts 'legacy initializer'") + + Dir.chdir(temp_dir) do + files = Roast::Initializers.send(:initializer_files) + assert_equal([local_init_file, global_init_file, legacy_init_file], files) end + end + end + + test "initializer_files includes legacy initializers" do + with_fake_xdg_env do |temp_dir| + temp_dir = File.realpath(temp_dir) + + legacy_init_file = File.join(temp_dir, ".roast", "initializers", "legacy.rb") + FileUtils.mkdir_p(File.dirname(legacy_init_file)) + File.write(legacy_init_file, "puts 'legacy initializer'") - assert_equal("", out) - expected_output = <<~OUTPUT - Loading project initializers from #{initializer_path} - Loading initializer: #{File.join(initializer_path, "first.rb")} - Loading initializer: #{File.join(initializer_path, "second.rb")} - Loading initializer: #{File.join(initializer_path, "third.rb")} - OUTPUT - assert_equal(expected_output, err) + Dir.chdir(temp_dir) do + files = Roast::Initializers.send(:initializer_files) + assert_includes(files, legacy_init_file) + end end end + + private + + def initializers_fixture_path(name) + File.join(Dir.pwd, "test", "fixtures", "initializers", name) + end end end diff --git a/test/roast/workflow/file_state_repository_test.rb b/test/roast/workflow/file_state_repository_test.rb index abce743d..b0c43d8f 100644 --- a/test/roast/workflow/file_state_repository_test.rb +++ b/test/roast/workflow/file_state_repository_test.rb @@ -5,8 +5,11 @@ module Roast module Workflow class FileStateRepositoryTest < ActiveSupport::TestCase + include XDGHelper + def setup @temp_dir = Dir.mktmpdir + stub_xdg_env(@temp_dir) @file = File.join(@temp_dir, "test.rb") @session_name = "test_workflow" @session_manager = SessionManager.new @@ -28,6 +31,7 @@ def setup end def teardown + unstub_xdg_env FileUtils.remove_entry(@temp_dir) if @temp_dir && File.exist?(@temp_dir) Dir.unstub(:pwd) end @@ -193,7 +197,8 @@ def expected_workflow_dir file_id = Digest::MD5.hexdigest(@workflow.file) file_basename = File.basename(@workflow.file).parameterize.underscore human_readable_id = "#{file_basename}_#{file_id[0..7]}" - File.join(@temp_dir, ".roast", "sessions", workflow_dir_name, human_readable_id) + # Use XDG state directory in tests + File.join(SESSION_DATA_DIR, workflow_dir_name, human_readable_id) end def create_test_state(step_name, order, additional_data = {}) diff --git a/test/roast/workflow/json_replay_test.rb b/test/roast/workflow/json_replay_test.rb index 2823b02b..d2af2f02 100644 --- a/test/roast/workflow/json_replay_test.rb +++ b/test/roast/workflow/json_replay_test.rb @@ -5,7 +5,10 @@ module Roast module Workflow class JsonReplayTest < ActiveSupport::TestCase + include XDGHelper + setup do + stub_xdg_env(Dir.mktmpdir) @workflow = BaseWorkflow.new(nil, name: "test_workflow") @workflow.session_name = "test_session" @workflow.storage_type = "file" # Force file storage for this test @@ -13,6 +16,10 @@ class JsonReplayTest < ActiveSupport::TestCase @state_manager = StateManager.new(@workflow, state_repository: @state_repository) end + teardown do + unstub_xdg_env + end + test "JSON response preserved as hash through save/load cycle" do # Step 1: Simulate a step that returns JSON step1_name = "fetch_data" diff --git a/test/roast/workflow/session_manager_test.rb b/test/roast/workflow/session_manager_test.rb index a4f61a9c..294c5b7e 100644 --- a/test/roast/workflow/session_manager_test.rb +++ b/test/roast/workflow/session_manager_test.rb @@ -5,8 +5,11 @@ module Roast module Workflow class SessionManagerTest < ActiveSupport::TestCase + include XDGHelper + def setup @temp_dir = Dir.mktmpdir + stub_xdg_env(@temp_dir) @file = File.join(@temp_dir, "test.rb") @workflow_id = 12345 @session_name = "test_workflow" @@ -17,6 +20,7 @@ def setup end def teardown + unstub_xdg_env FileUtils.remove_entry(@temp_dir) if @temp_dir && File.exist?(@temp_dir) end @@ -87,7 +91,8 @@ def expected_workflow_dir file_id = Digest::MD5.hexdigest(@file) file_basename = File.basename(@file).parameterize.underscore human_readable_id = "#{file_basename}_#{file_id[0..7]}" - File.join(@temp_dir, ".roast", "sessions", workflow_dir_name, human_readable_id) + # Use XDG state directory in tests + File.join(Roast::SESSION_DATA_DIR, workflow_dir_name, human_readable_id) end end end diff --git a/test/roast/workflow/workflow_initializer_test.rb b/test/roast/workflow/workflow_initializer_test.rb index b8da889e..cfaecac4 100644 --- a/test/roast/workflow/workflow_initializer_test.rb +++ b/test/roast/workflow/workflow_initializer_test.rb @@ -18,7 +18,7 @@ def teardown end def test_setup_loads_initializers_and_configures_tools - Roast::Initializers.expects(:load_all) + Roast::Initializers.expects(:load_all).with(@configuration.context_path) @initializer.setup end diff --git a/test/roast/xdg_migration_test.rb b/test/roast/xdg_migration_test.rb new file mode 100644 index 00000000..5fcbf263 --- /dev/null +++ b/test/roast/xdg_migration_test.rb @@ -0,0 +1,577 @@ +# frozen_string_literal: true + +require "test_helper" + +module Roast + class XDGMigrationTest < ActiveSupport::TestCase + include XDGHelper + + def setup + super + ::CLI::UI.stubs(:confirm).returns(false) + ::CLI::UI::Prompt.stubs(:confirm).returns(false) + end + + test "migrate does nothing when no legacy directory exists" do + with_fake_xdg_env do |temp_dir| + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: true) + migration.migrate + end + end + + assert_empty output[0] # stdout should be empty + refute_directory_exists(Roast::CONFIG_DIR) + refute_directory_exists(Roast::CACHE_DIR) + end + end + + test "migrate migrates cache files" do + with_fake_xdg_env do |temp_dir| + # Create legacy cache structure + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + create_test_file(legacy_cache_dir, "function1.cache", "cached function 1") + create_test_file(legacy_cache_dir, "subdir/function2.cache", "cached function 2") + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: true) + migration.migrate + end + end + + assert_migration_output(output[0], "function cache") + assert_migrated_files( + Roast::FUNCTION_CACHE_DIR, + "function1.cache" => "cached function 1", + "subdir/function2.cache" => "cached function 2", + ) + end + end + + test "migrate migrates session files" do + with_fake_xdg_env do |temp_dir| + # Create legacy sessions structure + legacy_sessions_dir = create_legacy_directory(temp_dir, "sessions") + create_test_file(legacy_sessions_dir, "workflow1/session1/step_1.json", '{"step": "data"}') + create_test_file(legacy_sessions_dir, "workflow2/session2/final_output.txt", "final output") + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: true) + migration.migrate + end + end + + assert_migration_output(output[0], "session state") + assert_migrated_files( + Roast::SESSION_DATA_DIR, + "workflow1/session1/step_1.json" => '{"step": "data"}', + "workflow2/session2/final_output.txt" => "final output", + ) + end + end + + test "migrate migrates sessions database" do + with_fake_xdg_env do |temp_dir| + refute(File.exist?(Roast::SESSION_DB_PATH)) + + # Create legacy sessions database + legacy_sessions_db_path = File.join(temp_dir, ".roast", "sessions.db") + FileUtils.mkdir_p(File.dirname(legacy_sessions_db_path)) + FileUtils.touch(legacy_sessions_db_path) + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: true) + migration.migrate + end + end + + assert_migration_output(output[0], "session database") + assert(File.exist?(Roast::SESSION_DB_PATH)) + end + end + + test "migrate migrates all types together" do + Roast::XDGMigration.unstub(:migrate) + with_fake_xdg_env do |temp_dir| + # Create all legacy directory types + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + legacy_sessions_dir = create_legacy_directory(temp_dir, "sessions") + legacy_initializers_dir = create_legacy_directory(temp_dir, "initializers") + + create_test_file(legacy_cache_dir, "cache_file.dat", "cache data") + create_test_file(legacy_sessions_dir, "session_file.json", "session data") + create_test_file(legacy_initializers_dir, "init_file.rb", "init data") + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(workflow_context_path: temp_dir, auto_confirm: true) + migration.migrate + end + end + + output_text = output[0] + assert_includes output_text, "Items to migrate" + assert_includes output_text, "function cache" + assert_includes output_text, "session state" + assert_includes output_text, "initializers" + # Migration is complete because all existing candidates (cache, sessions, initializers) are migrated + assert_includes output_text, "Migration complete!" + + # Verify all files migrated + assert_migrated_files( + Roast::FUNCTION_CACHE_DIR, + "cache_file.dat" => "cache data", + ) + assert_migrated_files( + Roast::SESSION_DATA_DIR, + "session_file.json" => "session data", + ) + assert_migrated_files( + File.join(temp_dir, "initializers"), + "init_file.rb" => "init data", + ) + end + end + + test "migrate skips empty legacy directories" do + with_fake_xdg_env do |temp_dir| + # Create empty legacy directories + create_legacy_directory(temp_dir, "cache") + create_legacy_directory(temp_dir, "sessions") + # Don't create initializers to test partial migration + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: true) + migration.migrate + end + end + + output_text = output[0] + # No items to migrate since directories are empty and not migratable + # Migration shows incomplete because existing empty directories can't be migrated + assert_includes output_text, "Migration incomplete!" + # Should mention existing empty directories that can't be migrated + assert_includes output_text, "cache" + assert_includes output_text, "sessions" + # Should NOT mention initializers since it doesn't exist + refute_includes output_text, "initializers" + end + end + + test "migrate doesn't overwrite existing files" do + with_fake_xdg_env do |temp_dir| + # Create legacy cache + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + create_test_file(legacy_cache_dir, "existing.cache", "legacy content") + + # Create existing XDG file + xdg_cache_dir = Roast::FUNCTION_CACHE_DIR + create_test_file(xdg_cache_dir, "existing.cache", "xdg content") + + # Mock prompts to decline migration + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to migrate")).returns(false) + + capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: false) + migration.migrate + end + end + + # File should retain XDG content, not be overwritten + assert_file_content(File.join(xdg_cache_dir, "existing.cache"), "xdg content") + end + end + + test "migrate finds legacy directory in parent directories" do + with_fake_xdg_env do |temp_dir| + # Create nested directory structure + nested_dir = File.join(temp_dir, "project", "subdir", "deeper") + FileUtils.mkdir_p(nested_dir) + + # Create legacy .roast in parent + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + create_test_file(legacy_cache_dir, "test.cache", "test data") + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(nested_dir) do + migration = Roast::XDGMigration.new(auto_confirm: true) + migration.migrate + end + end + + assert_includes output[0], "Items to migrate" + assert_migrated_files( + Roast::FUNCTION_CACHE_DIR, + "test.cache" => "test data", + ) + end + end + + test "find_all_legacy_dot_roast_dirs finds .roast directory in current directory" do + with_fake_xdg_env do |temp_dir| + # Create .roast directory in temp_dir + roast_dir = File.join(temp_dir, ".roast") + FileUtils.mkdir_p(roast_dir) + + Dir.chdir(temp_dir) do + found_dirs = Roast::XDGMigration.find_all_legacy_dot_roast_dirs + assert_equal(File.realpath(roast_dir), File.realpath(found_dirs.first)) + end + end + end + + test "find_all_legacy_dot_roast_dirs finds .roast directory in parent directories" do + with_fake_xdg_env do |temp_dir| + # Create nested directory structure + nested_dir = File.join(temp_dir, "project", "subdir", "deeper") + FileUtils.mkdir_p(nested_dir) + + # Create .roast in parent + roast_dir = File.join(temp_dir, ".roast") + FileUtils.mkdir_p(roast_dir) + + Dir.chdir(nested_dir) do + found_dirs = Roast::XDGMigration.find_all_legacy_dot_roast_dirs + assert_equal(File.realpath(roast_dir), File.realpath(found_dirs.first)) + end + end + end + + test "find_all_legacy_dot_roast_dirs returns empty when no .roast directory exists" do + with_fake_xdg_env do |temp_dir| + found_dirs = Roast::XDGMigration.find_all_legacy_dot_roast_dirs(temp_dir) + assert_empty(found_dirs) + end + end + + test "find_all_legacy_dot_roast_dirs respects ending_path boundary" do + with_fake_xdg_env do |temp_dir| + # Create nested structure + ending_path = File.join(temp_dir, "boundary") + search_dir = File.join(ending_path, "project", "deep") + FileUtils.mkdir_p(search_dir) + + # Create .roast beyond the boundary + roast_beyond = File.join(temp_dir, ".roast") + FileUtils.mkdir_p(roast_beyond) + + Dir.chdir(search_dir) do + found_dirs = Roast::XDGMigration.find_all_legacy_dot_roast_dirs(search_dir, ending_path) + found_dir = found_dirs.first + refute_equal(roast_beyond, found_dir) + end + end + end + + test "migrate handles roast directory with random content" do + with_fake_xdg_env do |temp_dir| + # Create .roast directory with known and unknown content + roast_dir = File.join(temp_dir, ".roast") + FileUtils.mkdir_p(roast_dir) + + # Add known subdirectories + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + create_test_file(legacy_cache_dir, "valid.cache", "cache data") + + # Add random/unexpected content + create_test_file(roast_dir, "random_file.txt", "random content") + create_test_file(roast_dir, "config.yml", "some: config") + random_subdir = File.join(roast_dir, "unknown_subdir") + FileUtils.mkdir_p(random_subdir) + create_test_file(random_subdir, "stuff.rb", "puts 'hello'") + + # Add empty directory + FileUtils.mkdir_p(File.join(roast_dir, "empty_dir")) + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: true) + migration.migrate + end + end + + # Should migrate known content + assert_includes output[0], "Items to migrate" + assert_includes output[0], "function cache" + assert_migrated_files( + Roast::FUNCTION_CACHE_DIR, + "valid.cache" => "cache data", + ) + + # Random files should remain in .roast directory (not migrated) + assert_file_exists(File.join(roast_dir, "random_file.txt")) + assert_file_exists(File.join(roast_dir, "config.yml")) + assert_file_exists(File.join(random_subdir, "stuff.rb")) + assert_directory_exists(File.join(roast_dir, "empty_dir")) + end + end + + test "migrate_initializers doesn't overwrite existing files in target directory" do + with_fake_xdg_env do |temp_dir| + # Create legacy initializers directory + legacy_initializers_dir = create_legacy_directory(temp_dir, "initializers") + create_test_file(legacy_initializers_dir, "config.rb", "# legacy config") + create_test_file(legacy_initializers_dir, "setup.rb", "# legacy setup") + create_test_file(legacy_initializers_dir, "subdir/nested.rb", "# legacy nested") + + # Create existing initializers directory with some files + existing_initializers_dir = File.join(temp_dir, "initializers") + create_test_file(existing_initializers_dir, "config.rb", "# existing config") + create_test_file(existing_initializers_dir, "other.rb", "# existing other") + + # Mock prompts - confirm migration, decline overwrite for directory + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to migrate")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Non empty directory already exists")).returns(false) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to delete")).returns(false) + + output = capture_io do + # Ensure logger uses captured stdout + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(workflow_context_path: temp_dir, auto_confirm: false) + migration.migrate + end + end + + assert_includes output[0], "Items to migrate" + assert_includes output[0], "initializers" + + # Existing files should not be overwritten since directory overwrite was declined + assert_file_content(File.join(existing_initializers_dir, "config.rb"), "# existing config") + assert_file_content(File.join(existing_initializers_dir, "other.rb"), "# existing other") + + # New files should NOT be migrated since directory overwrite was declined + refute(File.exist?(File.join(existing_initializers_dir, "setup.rb"))) + refute(File.exist?(File.join(existing_initializers_dir, "subdir/nested.rb"))) + end + end + + test "migrate_directory prompts for overwrite when directory exists and user confirms" do + with_fake_xdg_env do |temp_dir| + # Create legacy cache with files that will conflict + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + create_test_file(legacy_cache_dir, "existing.cache", "legacy content") + create_test_file(legacy_cache_dir, "new.cache", "new content") + + # Create existing XDG file + xdg_cache_dir = Roast::FUNCTION_CACHE_DIR + create_test_file(xdg_cache_dir, "existing.cache", "existing content") + + # Mock the prompts + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to migrate")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Non empty directory already exists")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to delete")).returns(false) + + capture_io do + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: false) + migration.migrate + end + end + + # File should be overwritten with legacy content + assert_file_content(File.join(xdg_cache_dir, "existing.cache"), "legacy content") + # New file should be migrated normally + assert_file_content(File.join(xdg_cache_dir, "new.cache"), "new content") + end + end + + test "migrate_directory skips overwrite when directory exists and user declines" do + with_fake_xdg_env do |temp_dir| + # Create legacy cache with files that will conflict + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + create_test_file(legacy_cache_dir, "existing.cache", "legacy content") + create_test_file(legacy_cache_dir, "new.cache", "new content") + + # Create existing XDG file + xdg_cache_dir = Roast::FUNCTION_CACHE_DIR + create_test_file(xdg_cache_dir, "existing.cache", "existing content") + + # Mock the prompts + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to migrate")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Non empty directory already exists")).returns(false) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to delete")).returns(false) + + capture_io do + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: false) + migration.migrate + end + end + + # File should retain existing content since directory overwrite was declined + assert_file_content(File.join(xdg_cache_dir, "existing.cache"), "existing content") + # New file should NOT be migrated since directory overwrite was declined + refute(File.exist?(File.join(xdg_cache_dir, "new.cache"))) + end + end + + test "migrate_file prompts for overwrite when file exists and user confirms" do + with_fake_xdg_env do |temp_dir| + # Create legacy sessions database + legacy_sessions_db_path = File.join(temp_dir, ".roast", "sessions.db") + FileUtils.mkdir_p(File.dirname(legacy_sessions_db_path)) + File.write(legacy_sessions_db_path, "legacy db content") + + # Create existing sessions database + FileUtils.mkdir_p(File.dirname(Roast::SESSION_DB_PATH)) + File.write(Roast::SESSION_DB_PATH, "existing db content") + + # Mock the sequence of prompts + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to migrate")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with("File already exists at #{Roast::SESSION_DB_PATH}. Do you want to overwrite it?").returns(true) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to delete")).returns(false) + + capture_io do + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: false) + migration.migrate + end + end + + # File should be overwritten with legacy content + assert_file_content(Roast::SESSION_DB_PATH, "legacy db content") + end + end + + test "migrate_file skips overwrite when file exists and user declines" do + with_fake_xdg_env do |temp_dir| + # Create legacy sessions database + legacy_sessions_db_path = File.join(temp_dir, ".roast", "sessions.db") + FileUtils.mkdir_p(File.dirname(legacy_sessions_db_path)) + File.write(legacy_sessions_db_path, "legacy db content") + + # Create existing sessions database + FileUtils.mkdir_p(File.dirname(Roast::SESSION_DB_PATH)) + File.write(Roast::SESSION_DB_PATH, "existing db content") + + # Mock the prompts + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to migrate")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with("File already exists at #{Roast::SESSION_DB_PATH}. Do you want to overwrite it?").returns(false) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to delete")).returns(false) + + capture_io do + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: false) + migration.migrate + end + end + + # File should retain existing content + assert_file_content(Roast::SESSION_DB_PATH, "existing db content") + end + end + + test "migrate_directory handles directory overwrite when user confirms" do + with_fake_xdg_env do |temp_dir| + # Create legacy cache with multiple conflicting files + legacy_cache_dir = create_legacy_directory(temp_dir, "cache") + create_test_file(legacy_cache_dir, "file1.cache", "legacy file1") + create_test_file(legacy_cache_dir, "file2.cache", "legacy file2") + create_test_file(legacy_cache_dir, "file3.cache", "legacy file3") + + # Create existing XDG files + xdg_cache_dir = Roast::FUNCTION_CACHE_DIR + create_test_file(xdg_cache_dir, "file1.cache", "existing file1") + create_test_file(xdg_cache_dir, "file2.cache", "existing file2") + + # Mock the prompts + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to migrate")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Non empty directory already exists")).returns(true) + ::CLI::UI::Prompt.expects(:confirm).with(includes("Would you like to delete")).returns(false) + + capture_io do + Roast::Helpers::Logger.reset + Dir.chdir(temp_dir) do + migration = Roast::XDGMigration.new(auto_confirm: false) + migration.migrate + end + end + + # All files should be overwritten since directory overwrite was confirmed + assert_file_content(File.join(xdg_cache_dir, "file1.cache"), "legacy file1") + assert_file_content(File.join(xdg_cache_dir, "file2.cache"), "legacy file2") + assert_file_content(File.join(xdg_cache_dir, "file3.cache"), "legacy file3") + end + end + + private + + def create_legacy_directory(temp_dir, subdir_name) + legacy_roast_dir = File.join(temp_dir, ".roast") + dir_path = File.join(legacy_roast_dir, subdir_name) + FileUtils.mkdir_p(dir_path) + dir_path + end + + def assert_migration_output(output, description) + assert_includes(output, "Items to migrate") + assert_includes(output, description) + assert_includes(output, "Migration complete!") + end + + def assert_migrated_files(target_dir, file_expectations) + file_expectations.each do |relative_path, expected_content| + file_path = File.join(target_dir, relative_path) + assert_file_exists(file_path) + assert_file_content(file_path, expected_content) + end + end + + def assert_file_exists(file_path) + assert(File.exist?(file_path), "Expected file to exist: #{file_path}") + end + + def assert_file_content(file_path, expected_content) + actual_content = File.read(file_path) + assert_equal( + expected_content, + actual_content, + "File content mismatch in #{file_path}", + ) + end + + def assert_directory_exists(dir_path) + assert(File.directory?(dir_path), "Expected directory to exist: #{dir_path}") + end + + def refute_directory_exists(dir_path) + refute(File.directory?(dir_path), "Expected directory to not exist: #{dir_path}") + end + + def create_test_file(base_dir, relative_path, content) + file_path = File.join(base_dir, relative_path) + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, content) + file_path + end + end +end diff --git a/test/support/redefine_constants.rb b/test/support/redefine_constants.rb new file mode 100644 index 00000000..48998c13 --- /dev/null +++ b/test/support/redefine_constants.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +# typed: false + +module RedefineConstants + def redefine_constant(mod, constant, new_value) + @redefined_constants ||= [] + @redefined_constants << [mod, constant, mod.const_get(constant)] + ignore_constant_redefined_warnings do + mod.const_set(constant, new_value) + end + end + + def with_redefined_constant(mod, constant, new_value) + redefine_constant(mod, constant, new_value) + yield + ensure + @redefined_constants.reject! do |xmod, xconstant, old_value| + next(false) unless mod == xmod && constant == xconstant + + ignore_constant_redefined_warnings do + mod.const_set(constant, old_value) + end + true + end + end + + def reset_constants + return unless @redefined_constants + + @redefined_constants.each do |mod, constant, old_value| + ignore_constant_redefined_warnings do + mod.const_set(constant, old_value) + end + end + + @redefine_constants = nil + end + + def ignore_constant_redefined_warnings + warn_level = $VERBOSE + $VERBOSE = nil + yield + $VERBOSE = warn_level + end + + def teardown + reset_constants + super + end +end diff --git a/test/support/xdg_helper.rb b/test/support/xdg_helper.rb new file mode 100644 index 00000000..e97452e6 --- /dev/null +++ b/test/support/xdg_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module XDGHelper + include RedefineConstants + + def with_fake_xdg_env + Dir.mktmpdir do |temp_dir| + stub_xdg_env(temp_dir) + + yield temp_dir + ensure + unstub_xdg_env + end + end + + def stub_xdg_env(temp_dir) + redefine_constant(Roast, :XDG_CONFIG_HOME, File.join(temp_dir, ".config")) + redefine_constant(Roast, :XDG_CACHE_HOME, File.join(temp_dir, ".cache")) + + redefine_constant(Roast, :CONFIG_DIR, File.join(Roast::XDG_CONFIG_HOME, "roast")) + redefine_constant(Roast, :CACHE_DIR, File.join(Roast::XDG_CACHE_HOME, "roast")) + + redefine_constant(Roast, :GLOBAL_INITIALIZERS_DIR, File.join(Roast::CONFIG_DIR, "initializers")) + redefine_constant(Roast, :FUNCTION_CACHE_DIR, File.join(Roast::CACHE_DIR, "function_calls")) + redefine_constant(Roast, :SESSION_DATA_DIR, File.join(Roast::CACHE_DIR, "sessions")) + redefine_constant(Roast, :SESSION_DB_PATH, File.join(Roast::CACHE_DIR, "sessions.db")) + end + + def unstub_xdg_env + reset_constants + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 01e7997f..fcec20c1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -18,8 +18,10 @@ require "webmock/minitest" # Test support files -require "support/fixture_helpers" require "support/improved_assertions" +require "support/redefine_constants" +require "support/fixture_helpers" +require "support/xdg_helper" # Turn on color during CI since GitHub Actions supports it if ENV["CI"]